From a0e534687355833cd05a9389046a8d1335799d6a Mon Sep 17 00:00:00 2001 From: Zane Chee Date: Wed, 25 Mar 2026 09:46:29 +0800 Subject: [PATCH 1/3] Add TinyDetective cookbook project --- README.md | 2 +- TinyDetective/.env.example | 24 + TinyDetective/.gitignore | 211 + TinyDetective/README.md | 134 + TinyDetective/adapters/__init__.py | 1 + .../adapters/comparison_site_adapter.py | 170 + .../adapters/official_product_adapter.py | 126 + .../adapters/seller_listing_adapter.py | 144 + TinyDetective/adapters/seller_page_adapter.py | 108 + TinyDetective/adapters/source_page_adapter.py | 84 + TinyDetective/agents/__init__.py | 1 + .../agents/candidate_discovery_agent.py | 162 + .../agents/candidate_triage_agent.py | 180 + TinyDetective/agents/case_draft_agent.py | 98 + TinyDetective/agents/evidence_agent.py | 85 + .../agents/official_product_match_agent.py | 81 + .../agents/product_comparison_agent.py | 284 ++ TinyDetective/agents/ranking_agent.py | 24 + .../agents/reasoning_enrichment_agent.py | 190 + .../agents/research_summary_agent.py | 45 + TinyDetective/agents/seller_evidence_agent.py | 121 + .../agents/seller_listing_analysis_agent.py | 70 + .../agents/seller_listing_discovery_agent.py | 61 + .../agents/seller_listing_triage_agent.py | 195 + TinyDetective/agents/seller_profile_agent.py | 56 + .../agents/source_extraction_agent.py | 51 + TinyDetective/backend/__init__.py | 1 + TinyDetective/backend/__main__.py | 9 + TinyDetective/backend/main.py | 159 + .../docs/seller-case-feature-plan.md | 605 +++ TinyDetective/frontend/app.js | 3781 +++++++++++++++++ TinyDetective/frontend/favicon.svg | 12 + TinyDetective/frontend/index.html | 519 +++ TinyDetective/frontend/logos/amazon.png | Bin 0 -> 2054 bytes TinyDetective/frontend/logos/amazon.svg | 9 + TinyDetective/frontend/logos/ebay.svg | 1 + .../frontend/logos/facebook-marketplace.svg | 6 + TinyDetective/frontend/logos/facebook.svg | 1 + TinyDetective/frontend/logos/lazada.png | Bin 0 -> 19402 bytes TinyDetective/frontend/logos/lazada.svg | 425 ++ TinyDetective/frontend/logos/shopee.png | Bin 0 -> 1056 bytes TinyDetective/frontend/logos/shopee.svg | 5 + TinyDetective/frontend/styles.css | 2181 ++++++++++ TinyDetective/frontend/tinydetective.png | Bin 0 -> 129723 bytes .../frontend/tinydetective_nofish.png | Bin 0 -> 50248 bytes .../frontend/vendor/jspdf.umd.min.js | 398 ++ TinyDetective/models/__init__.py | 1 + TinyDetective/models/case_schemas.py | 159 + TinyDetective/models/schemas.py | 185 + TinyDetective/pyproject.toml | 18 + TinyDetective/services/__init__.py | 1 + .../services/investigation_orchestrator.py | 1171 +++++ TinyDetective/services/investigation_store.py | 543 +++ TinyDetective/services/logging_config.py | 33 + TinyDetective/services/openai_client.py | 112 + .../services/seller_case_orchestrator.py | 1392 ++++++ TinyDetective/services/settings.py | 81 + TinyDetective/services/tinyfish_client.py | 225 + TinyDetective/services/tinyfish_runtime.py | 16 + TinyDetective/tests/__init__.py | 1 + .../fixtures/sample_investigation_output.json | 22 + TinyDetective/tests/test_agents.py | 332 ++ TinyDetective/tests/test_orchestrator.py | 496 +++ .../tests/test_seller_case_orchestrator.py | 250 ++ TinyDetective/uv.lock | 324 ++ 65 files changed, 16181 insertions(+), 1 deletion(-) create mode 100644 TinyDetective/.env.example create mode 100644 TinyDetective/.gitignore create mode 100644 TinyDetective/README.md create mode 100644 TinyDetective/adapters/__init__.py create mode 100644 TinyDetective/adapters/comparison_site_adapter.py create mode 100644 TinyDetective/adapters/official_product_adapter.py create mode 100644 TinyDetective/adapters/seller_listing_adapter.py create mode 100644 TinyDetective/adapters/seller_page_adapter.py create mode 100644 TinyDetective/adapters/source_page_adapter.py create mode 100644 TinyDetective/agents/__init__.py create mode 100644 TinyDetective/agents/candidate_discovery_agent.py create mode 100644 TinyDetective/agents/candidate_triage_agent.py create mode 100644 TinyDetective/agents/case_draft_agent.py create mode 100644 TinyDetective/agents/evidence_agent.py create mode 100644 TinyDetective/agents/official_product_match_agent.py create mode 100644 TinyDetective/agents/product_comparison_agent.py create mode 100644 TinyDetective/agents/ranking_agent.py create mode 100644 TinyDetective/agents/reasoning_enrichment_agent.py create mode 100644 TinyDetective/agents/research_summary_agent.py create mode 100644 TinyDetective/agents/seller_evidence_agent.py create mode 100644 TinyDetective/agents/seller_listing_analysis_agent.py create mode 100644 TinyDetective/agents/seller_listing_discovery_agent.py create mode 100644 TinyDetective/agents/seller_listing_triage_agent.py create mode 100644 TinyDetective/agents/seller_profile_agent.py create mode 100644 TinyDetective/agents/source_extraction_agent.py create mode 100644 TinyDetective/backend/__init__.py create mode 100644 TinyDetective/backend/__main__.py create mode 100644 TinyDetective/backend/main.py create mode 100644 TinyDetective/docs/seller-case-feature-plan.md create mode 100644 TinyDetective/frontend/app.js create mode 100644 TinyDetective/frontend/favicon.svg create mode 100644 TinyDetective/frontend/index.html create mode 100644 TinyDetective/frontend/logos/amazon.png create mode 100644 TinyDetective/frontend/logos/amazon.svg create mode 100644 TinyDetective/frontend/logos/ebay.svg create mode 100644 TinyDetective/frontend/logos/facebook-marketplace.svg create mode 100644 TinyDetective/frontend/logos/facebook.svg create mode 100644 TinyDetective/frontend/logos/lazada.png create mode 100644 TinyDetective/frontend/logos/lazada.svg create mode 100644 TinyDetective/frontend/logos/shopee.png create mode 100644 TinyDetective/frontend/logos/shopee.svg create mode 100644 TinyDetective/frontend/styles.css create mode 100644 TinyDetective/frontend/tinydetective.png create mode 100644 TinyDetective/frontend/tinydetective_nofish.png create mode 100644 TinyDetective/frontend/vendor/jspdf.umd.min.js create mode 100644 TinyDetective/models/__init__.py create mode 100644 TinyDetective/models/case_schemas.py create mode 100644 TinyDetective/models/schemas.py create mode 100644 TinyDetective/pyproject.toml create mode 100644 TinyDetective/services/__init__.py create mode 100644 TinyDetective/services/investigation_orchestrator.py create mode 100644 TinyDetective/services/investigation_store.py create mode 100644 TinyDetective/services/logging_config.py create mode 100644 TinyDetective/services/openai_client.py create mode 100644 TinyDetective/services/seller_case_orchestrator.py create mode 100644 TinyDetective/services/settings.py create mode 100644 TinyDetective/services/tinyfish_client.py create mode 100644 TinyDetective/services/tinyfish_runtime.py create mode 100644 TinyDetective/tests/__init__.py create mode 100644 TinyDetective/tests/fixtures/sample_investigation_output.json create mode 100644 TinyDetective/tests/test_agents.py create mode 100644 TinyDetective/tests/test_orchestrator.py create mode 100644 TinyDetective/tests/test_seller_case_orchestrator.py create mode 100644 TinyDetective/uv.lock diff --git a/README.md b/README.md index 26593b7fc..ca6c98bec 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Each folder in this repo is a standalone project. Dive in to see how to solve re | [stay-scout-hub](./stay-scout-hub) | Searches across all sites for places to stay when traveling for conventions or events | | [summer-school-finder](./summer-school-finder) | Discover and compare summer school programs from universities around the world | | [tenders-finder](./tenders-finder) | AI-powered Singapore government tender discovery tool scraping multiple tender portals in parallel | +| [TinyDetective](./TinyDetective) | Brand protection for Vietnam's ecommerce industry, using TinyFish agent swarms for parallel counterfeit detection & reporting | | [tinyskills](./tinyskills) | Multi-source AI skill guide generator | | [tutor-finder](./tutor-finder) | AI-powered tutor discovery platform for competitive exams across multiple platforms | | [viet-bike-scout](./viet-bike-scout) | Motorbike rental price comparison tool across Vietnamese cities using parallel browser agents | @@ -222,4 +223,3 @@ This repository is a community-driven space for sharing derivatives, code sample - diff --git a/TinyDetective/.env.example b/TinyDetective/.env.example new file mode 100644 index 000000000..8c534f9c8 --- /dev/null +++ b/TinyDetective/.env.example @@ -0,0 +1,24 @@ +# TinyFish API configuration +INVESTIGATION_STORE_PATH=data/investigations.sqlite3 +TINYFISH_API_KEY=replace-with-your-tinyfish-api-key +TINYFISH_BASE_URL=https://agent.tinyfish.ai +TINYFISH_BROWSER_PROFILE=stealth +TINYFISH_PROXY_ENABLED=false +TINYFISH_PROXY_COUNTRY_CODE=SG +TINYFISH_POLL_INTERVAL_SECONDS=2.0 +TINYFISH_HTTP_TIMEOUT_SECONDS=15.0 +TINYFISH_RUN_SOFT_TIMEOUT_SECONDS=300.0 +TINYFISH_RUN_HARD_TIMEOUT_SECONDS=1800.0 +TINYFISH_RUN_STALL_TIMEOUT_SECONDS=120.0 + +# OpenAI candidate triage configuration +OPENAI_API_KEY=replace-with-your-openai-api-key +OPENAI_BASE_URL=https://api.openai.com +OPENAI_TRIAGE_MODEL=gpt-5-mini +OPENAI_REASONING_MODEL=gpt-5-mini +OPENAI_HTTP_TIMEOUT_SECONDS=30.0 +OPENAI_SHORTLIST_LIMIT=6 + +# Brand and marketplace targets +BRAND_LANDING_PAGE_URL=https://www.yourbrand.com/ +ECOMMERCE_STORE_URLS=https://shopee.sg/,https://www.lazada.sg/ diff --git a/TinyDetective/.gitignore b/TinyDetective/.gitignore new file mode 100644 index 000000000..dfc3e1f5e --- /dev/null +++ b/TinyDetective/.gitignore @@ -0,0 +1,211 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +data/*.sqlite3 +data/*.sqlite +data/*.sqlite-journal +data/*.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/TinyDetective/README.md b/TinyDetective/README.md new file mode 100644 index 000000000..a5c6b4396 --- /dev/null +++ b/TinyDetective/README.md @@ -0,0 +1,134 @@ +# TinyDetective + +## Creators + +- [Darrius](https://github.com/darriusnjh) +- [Wei Sin Tai](https://github.com/weisintai) +- [Zane Chee](https://github.com/injaneity) + +TinyDetective was built at **LotusHacks x HackHarvard Vietnam 2026**, where it placed **second in the Enterprise track**. The project came out of the TinyFish-sponsored challenge and focused on a practical enterprise workflow: counterfeit research across official brand sites and marketplace listings. + +**Live link:** Local run only + +**Source repo:** https://github.com/darriusnjh/TinyDetective + +TinyDetective is a counterfeit research platform with a modular multi-agent pipeline. It accepts an official product URL plus marketplace URLs, runs TinyFish-powered source extraction and listing analysis, then returns ranked findings with evidence and a short risk summary. + +The app keeps the workflow intentionally modular: adapters handle site-specific extraction, agents score and summarize results, and the backend persists investigations so unfinished runs can resume after restarts. + +## Demo + +![TinyDetective](./frontend/tinydetective.png) + +## TinyFish API usage + +From `services/tinyfish_client.py`, TinyDetective starts an async TinyFish run, then polls the documented runs endpoint until structured JSON is ready: + +```python +payload = { + "url": url, + "goal": goal, + "browser_profile": self.browser_profile, + "api_integration": "tinydetective", +} +response = await asyncio.to_thread( + self._request_json, + "POST", + f"{self.base_url}/v1/automation/run-async", + payload, +) +run_id = response.get("run_id") + +response = await asyncio.to_thread( + self._request_json, + "POST", + f"{self.base_url}/v1/runs/batch", + {"run_ids": [run_id]}, +) +``` + +## How to run + +```bash +uv sync --dev +uv run python -m backend +``` + +You can also run the backend entry file directly: + +```bash +cd backend +uv run main.py +``` + +Open `http://127.0.0.1:8000`. + +Create `.env` from `.env.example` and set: + +```bash +TINYFISH_API_KEY=your-real-key +TINYFISH_HTTP_TIMEOUT_SECONDS=15.0 +TINYFISH_RUN_SOFT_TIMEOUT_SECONDS=300.0 +TINYFISH_RUN_HARD_TIMEOUT_SECONDS=1800.0 +TINYFISH_RUN_STALL_TIMEOUT_SECONDS=120.0 +BRAND_LANDING_PAGE_URL=https://www.yourbrand.com/ +ECOMMERCE_STORE_URLS=https://shopee.sg/,https://www.lazada.sg/ +INVESTIGATION_STORE_PATH=data/investigations.sqlite3 +``` + +If `comparison_sites` is omitted from `POST /investigate`, the backend falls back to `ECOMMERCE_STORE_URLS`. + +Run tests with: + +```bash +uv run pytest +``` + +## Architecture diagram + +```mermaid +graph TD + User[User] --> UI[Static frontend] + UI --> API[FastAPI backend] + API --> Orchestrator[Investigation orchestrator] + Orchestrator --> Store[(SQLite investigation store)] + Orchestrator --> SourceAdapter[Official product adapter] + Orchestrator --> DiscoveryAdapter[Comparison site adapter] + SourceAdapter --> TinyFish[TinyFish async runs] + DiscoveryAdapter --> TinyFish + Orchestrator --> SourceAgent[SourceExtractionAgent] + Orchestrator --> DiscoveryAgent[CandidateDiscoveryAgent] + Orchestrator --> ComparisonAgent[ProductComparisonAgent] + Orchestrator --> EvidenceAgent[EvidenceAgent] + Orchestrator --> RankingAgent[RankingAgent] + Orchestrator --> SummaryAgent[ResearchSummaryAgent] +``` + +## Project structure + +- `backend/`: FastAPI app and API entrypoint. +- `agents/`: Source extraction, discovery, comparison, evidence, ranking, and summary agents. +- `adapters/`: TinyFish-backed official-product extraction and marketplace candidate discovery adapters. +- `models/`: Typed Pydantic schemas for API payloads and pipeline data. +- `services/`: Investigation orchestrator, SQLite-backed persistence, and TinyFish runtime/client abstractions. +- `frontend/`: Minimal static UI for launching investigations and inspecting results. +- `tests/`: Basic tests and sample fixture output. + +## Workflow + +1. `POST /investigate` creates an investigation and starts async orchestration. +2. `SourceExtractionAgent` extracts a normalized `SourceProduct`. +3. `CandidateDiscoveryAgent` searches the target comparison sites with TinyFish. +4. `ProductComparisonAgent` scores similarity and counterfeit risk. +5. `EvidenceAgent` converts comparisons into audit-friendly evidence. +6. `RankingAgent` returns up to 5 precision-oriented matches. +7. `ResearchSummaryAgent` writes the final investigation summary. +8. `GET /investigation/{id}` returns status, reports, and raw agent outputs. + +## Notes + +- Investigation runs are stored in SQLite and survive backend restarts by default. +- The default database file is `data/investigations.sqlite3`, configurable with `INVESTIGATION_STORE_PATH`. +- The frontend restores the latest saved investigation after a page refresh using browser local storage. +- On backend startup, unfinished investigations are resumed from SQLite, including pending TinyFish provider runs that already have a saved `run_id`. +- Backend agent activity is written to `logs/tinydetective.log`. diff --git a/TinyDetective/adapters/__init__.py b/TinyDetective/adapters/__init__.py new file mode 100644 index 000000000..af9860485 --- /dev/null +++ b/TinyDetective/adapters/__init__.py @@ -0,0 +1 @@ +"""Pluggable adapters for extraction and candidate discovery.""" diff --git a/TinyDetective/adapters/comparison_site_adapter.py b/TinyDetective/adapters/comparison_site_adapter.py new file mode 100644 index 000000000..6cbf2dbf8 --- /dev/null +++ b/TinyDetective/adapters/comparison_site_adapter.py @@ -0,0 +1,170 @@ +"""TinyFish-backed marketplace discovery and extraction adapter.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any +from urllib.parse import urlparse + +from models.schemas import CandidateProduct, SourceProduct +from services.tinyfish_client import TinyFishClient, TinyFishRun + + +class TinyFishComparisonSiteAdapter: + """Use TinyFish to search marketplace sites and extract candidate product pages.""" + + def __init__(self, client: TinyFishClient | None = None) -> None: + self.client = client or TinyFishClient() + + async def search( + self, + source_product: SourceProduct, + comparison_site: str, + search_query: str, + top_n: int = 3, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[list[CandidateProduct], dict[str, Any]]: + marketplace = self._marketplace_name(comparison_site) + run = await self.client.run_json( + comparison_site, + self._search_goal(source_product, search_query, top_n), + on_update=on_update, + ) + result = self._coerce_result_object(run) + candidates = [ + CandidateProduct.model_validate( + { + **candidate, + "marketplace": candidate.get("marketplace") or marketplace, + "discovery_queries": [search_query], + } + ) + for candidate in result.get("candidates", []) + if candidate.get("product_url") + ] + return candidates[:top_n], self._raw_output(run, search_query) + + async def resume_search( + self, + source_product: SourceProduct, + comparison_site: str, + run_id: str, + search_query: str, + top_n: int = 3, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[list[CandidateProduct], dict[str, Any]]: + marketplace = self._marketplace_name(comparison_site) + run = await self.client.wait_for_run( + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + result = self._coerce_result_object(run) + candidates = [ + CandidateProduct.model_validate( + { + **candidate, + "marketplace": candidate.get("marketplace") or marketplace, + "discovery_queries": [search_query], + } + ) + for candidate in result.get("candidates", []) + if candidate.get("product_url") + ] + return candidates[:top_n], self._raw_output(run, search_query) + + @staticmethod + def _search_goal(source_product: SourceProduct, search_query: str, top_n: int) -> str: + return ( + f"You are investigating counterfeit or suspicious product listings. Search this marketplace or store " + f"for up to {top_n} candidate listings that may match the official source product. " + f"Use this derived search query exactly as your starting point: {search_query!r}. " + "This query came from the official product analysis step from the source URL. " + f"Official product details: brand={source_product.brand!r}, product_name={source_product.product_name!r}, " + f"category={source_product.category!r}, subcategory={source_product.subcategory!r}, " + f"price={source_product.price!r} {source_product.currency!r}, color={source_product.color!r}, " + f"size={source_product.size!r}, material={source_product.material!r}, model={source_product.model!r}, " + f"sku={source_product.sku!r}, features={source_product.features!r}. " + "Return valid JSON only with this exact shape: " + '{"candidates":[{"product_url":"","marketplace":"","seller_name":"","seller_store_url":"",' + '"seller_id":"","title":"","price":0,"currency":"","brand":"","color":"","size":"","material":"","model":"","sku":"",' + '"description":"","image_urls":[]}]} ' + "Only include real listing URLs found on this site. Do not fabricate listings." + ) + + async def fetch_candidate_product( + self, + candidate_url: str, + marketplace: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[CandidateProduct, dict[str, Any]]: + run = await self.client.run_json(candidate_url, self._candidate_goal(), on_update=on_update) + result = self._coerce_result_object(run) + result["product_url"] = candidate_url + result["marketplace"] = marketplace + return CandidateProduct.model_validate(result), self._raw_output(run) + + async def resume_candidate_product( + self, + candidate_url: str, + marketplace: str, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[CandidateProduct, dict[str, Any]]: + run = await self.client.wait_for_run( + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + result = self._coerce_result_object(run) + result["product_url"] = candidate_url + result["marketplace"] = marketplace + return CandidateProduct.model_validate(result), self._raw_output(run) + + @staticmethod + def _candidate_goal() -> str: + return ( + "Visit this product listing page and extract structured product data for counterfeit research. " + "Return valid JSON only with this exact shape: " + '{"seller_name":"","seller_store_url":"","seller_id":"","title":"","price":0,"currency":"","brand":"","color":"","size":"",' + '"material":"","model":"","sku":"","description":"","image_urls":[]} ' + "Use null for unknown scalar values and [] for unknown lists. Do not invent values." + ) + + @staticmethod + def _coerce_result_object(run: TinyFishRun) -> dict[str, Any]: + result = run.result + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + return json.loads(result) + except json.JSONDecodeError as exc: + raise ValueError(f"Marketplace result was not valid JSON: {result}") from exc + raise ValueError(f"Unexpected TinyFish marketplace result: {result!r}") + + @staticmethod + def _marketplace_name(site: str) -> str: + host = urlparse(site).netloc.lower().replace("www.", "") + return (host.split(".")[0] if host else site).title() + + @staticmethod + def _raw_output(run: TinyFishRun, search_query: str | None = None) -> dict[str, Any]: + return { + "tinyfish_run_id": run.run_id, + "tinyfish_status": run.status, + "tinyfish_result": run.result, + "tinyfish_elapsed_seconds": run.elapsed_seconds, + "tinyfish_delayed": run.delayed, + "tinyfish_last_heartbeat_at": run.last_heartbeat_at.isoformat() if run.last_heartbeat_at else None, + "tinyfish_last_progress_at": run.last_progress_at.isoformat() if run.last_progress_at else None, + "search_query": search_query or "", + } diff --git a/TinyDetective/adapters/official_product_adapter.py b/TinyDetective/adapters/official_product_adapter.py new file mode 100644 index 000000000..ec30a550a --- /dev/null +++ b/TinyDetective/adapters/official_product_adapter.py @@ -0,0 +1,126 @@ +"""TinyFish-backed official product discovery adapter for seller-case matching.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any +from urllib.parse import urlparse + +from models.case_schemas import OfficialProductMatch, SellerListing +from models.schemas import SourceProduct +from services.settings import settings +from services.tinyfish_client import TinyFishClient, TinyFishRun + + +class TinyFishOfficialProductAdapter: + """Find the closest official product page for a seller listing on the brand's own website.""" + + def __init__(self, client: TinyFishClient | None = None) -> None: + self.client = client or TinyFishClient() + + async def discover_official_product( + self, + source_product: SourceProduct, + listing: SellerListing, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[OfficialProductMatch, dict[str, Any]]: + entry_url = self._official_entry_url(source_product) + run = await self.client.run_json( + entry_url, + self._goal(entry_url, source_product, listing), + on_update=on_update, + ) + return self._coerce_match(run, source_product, listing), self._raw_output(run) + + async def resume_discover_official_product( + self, + source_product: SourceProduct, + listing: SellerListing, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[OfficialProductMatch, dict[str, Any]]: + run = await self.client.wait_for_run( + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + return self._coerce_match(run, source_product, listing), self._raw_output(run) + + @staticmethod + def _official_entry_url(source_product: SourceProduct) -> str: + source_url = str(source_product.source_url) + source_host = urlparse(source_url).netloc.lower().replace("www.", "") + brand_url = settings.brand_landing_page_url + brand_host = urlparse(brand_url).netloc.lower().replace("www.", "") if brand_url else "" + if brand_url and brand_host and source_host and brand_host == source_host: + return brand_url + return brand_url or source_url + + @staticmethod + def _goal(entry_url: str, source_product: SourceProduct, listing: SellerListing) -> str: + return ( + "You are researching a suspicious marketplace listing and need to find the closest corresponding official product page " + f"on the brand's own website. Start from this official website entry URL: {entry_url!r}. " + f"Protected brand: {source_product.brand!r}. Seed official product URL: {source_product.source_url!r}. " + f"Marketplace listing URL: {listing.product_url!r}. " + f"Listing title: {listing.title!r}. Brand: {listing.brand!r}. Model: {listing.model!r}. SKU: {listing.sku!r}. " + f"Color: {listing.color!r}. Material: {listing.material!r}. Description: {listing.description!r}. " + "Search or browse the official site for the closest corresponding product page. " + "Return valid JSON only with this exact shape: " + '{"official_product_url":"","match_confidence":0.0,"rationale":"","search_queries":[]}. ' + "If no defensible official match is found, return an empty string for official_product_url and explain why." + ) + + @staticmethod + def _coerce_match( + run: TinyFishRun, + source_product: SourceProduct, + listing: SellerListing, + ) -> OfficialProductMatch: + result = TinyFishOfficialProductAdapter._coerce_result_object(run) + official_url = result.get("official_product_url") or None + if official_url is None and str(listing.brand or "").lower() == str(source_product.brand or "").lower(): + official_url = str(source_product.source_url) + result["rationale"] = ( + result.get("rationale") + or "Fell back to the seed official product page because no better official-site match was found." + ) + result["match_confidence"] = max(float(result.get("match_confidence") or 0.0), 0.38) + return OfficialProductMatch.model_validate( + { + "product_url": str(listing.product_url), + "official_product_url": official_url, + "match_confidence": result.get("match_confidence") or 0.0, + "rationale": result.get("rationale") or "", + "search_queries": result.get("search_queries") or [], + } + ) + + @staticmethod + def _coerce_result_object(run: TinyFishRun) -> dict[str, Any]: + result = run.result + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + return json.loads(result) + except json.JSONDecodeError as exc: + raise ValueError(f"Official product discovery was not valid JSON: {result}") from exc + raise ValueError(f"Unexpected TinyFish official product discovery result: {result!r}") + + @staticmethod + def _raw_output(run: TinyFishRun) -> dict[str, Any]: + return { + "tinyfish_run_id": run.run_id, + "tinyfish_status": run.status, + "tinyfish_result": run.result, + "tinyfish_elapsed_seconds": run.elapsed_seconds, + "tinyfish_delayed": run.delayed, + "tinyfish_last_heartbeat_at": run.last_heartbeat_at.isoformat() if run.last_heartbeat_at else None, + "tinyfish_last_progress_at": run.last_progress_at.isoformat() if run.last_progress_at else None, + } diff --git a/TinyDetective/adapters/seller_listing_adapter.py b/TinyDetective/adapters/seller_listing_adapter.py new file mode 100644 index 000000000..5704bbf5b --- /dev/null +++ b/TinyDetective/adapters/seller_listing_adapter.py @@ -0,0 +1,144 @@ +"""TinyFish-backed seller listing discovery adapter.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any +from urllib.parse import urlparse + +from models.case_schemas import SellerListing, SellerProfile +from models.schemas import ComparisonResult, SourceProduct +from services.tinyfish_client import TinyFishClient, TinyFishRun + + +class TinyFishSellerListingAdapter: + """Discover related listings from a seller storefront using TinyFish.""" + + def __init__(self, client: TinyFishClient | None = None) -> None: + self.client = client or TinyFishClient() + + async def discover_listings( + self, + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + entry_url: str, + top_n: int, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[list[SellerListing], dict[str, Any]]: + run = await self.client.run_json( + entry_url, + self._goal(source_product, seller_profile, selected_listing, entry_url, top_n), + on_update=on_update, + ) + return self._coerce_listings(run, seller_profile, selected_listing, entry_url, top_n), self._raw_output(run) + + async def resume_discover_listings( + self, + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + entry_url: str, + run_id: str, + top_n: int, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[list[SellerListing], dict[str, Any]]: + run = await self.client.wait_for_run( + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + return self._coerce_listings(run, seller_profile, selected_listing, entry_url, top_n), self._raw_output(run) + + @staticmethod + def _goal( + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + entry_url: str, + top_n: int, + ) -> str: + return ( + "You are investigating a seller suspected of listing counterfeit or infringing goods. " + f"Start from this seller storefront entry or shard URL: {entry_url!r}. " + f"Seller name: {seller_profile.seller_name!r}. Seller storefront URL: {seller_profile.seller_url!r}. " + f"Seed suspicious listing URL: {selected_listing.product_url!r}. " + f"Protected brand: {source_product.brand!r}. Product name: {source_product.product_name!r}. " + f"Category: {source_product.category!r}. Subcategory: {source_product.subcategory!r}. " + f"Model: {source_product.model!r}. SKU: {source_product.sku!r}. " + f"Color: {source_product.color!r}. Material: {source_product.material!r}. " + f"Key features: {source_product.features!r}. " + f"Navigate this seller storefront and return up to {top_n} listing URLs that appear most relevant to the protected brand " + "or product family, including visually or semantically similar variants if present. " + "Return valid JSON only with this exact shape: " + '{"seller_listings":[{"product_url":"","marketplace":"","seller_name":"","seller_store_url":"",' + '"seller_id":"","title":"","price":0,"currency":"","brand":"","color":"","size":"","material":"",' + '"model":"","sku":"","description":"","image_urls":[]}]} ' + "Only include listings actually visible from this seller inventory. Do not fabricate URLs." + ) + + @staticmethod + def _coerce_listings( + run: TinyFishRun, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + entry_url: str, + top_n: int, + ) -> list[SellerListing]: + result = TinyFishSellerListingAdapter._coerce_result_object(run) + marketplace = seller_profile.marketplace or selected_listing.marketplace or TinyFishSellerListingAdapter._marketplace_name( + str(selected_listing.product_url) + ) + seller_name = seller_profile.seller_name or selected_listing.candidate_product.seller_name + seller_url = str(seller_profile.seller_url or selected_listing.candidate_product.seller_store_url or "") + listings = [ + SellerListing.model_validate( + { + **listing, + "marketplace": listing.get("marketplace") or marketplace, + "seller_name": listing.get("seller_name") or seller_name, + "seller_store_url": listing.get("seller_store_url") or seller_url or None, + "seller_id": listing.get("seller_id") or seller_profile.seller_id, + "discovery_entry_url": listing.get("discovery_entry_url") or entry_url, + "discovery_shard_url": listing.get("discovery_shard_url") or entry_url, + "discovery_source": listing.get("discovery_source") or "seller_storefront_shard", + } + ) + for listing in result.get("seller_listings", []) + if listing.get("product_url") + ] + return listings[:top_n] + + @staticmethod + def _coerce_result_object(run: TinyFishRun) -> dict[str, Any]: + result = run.result + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + return json.loads(result) + except json.JSONDecodeError as exc: + raise ValueError(f"Seller listing discovery was not valid JSON: {result}") from exc + raise ValueError(f"Unexpected TinyFish seller listing result: {result!r}") + + @staticmethod + def _marketplace_name(site: str) -> str: + host = urlparse(site).netloc.lower().replace("www.", "") + return (host.split(".")[0] if host else site).title() + + @staticmethod + def _raw_output(run: TinyFishRun) -> dict[str, Any]: + return { + "tinyfish_run_id": run.run_id, + "tinyfish_status": run.status, + "tinyfish_result": run.result, + "tinyfish_elapsed_seconds": run.elapsed_seconds, + "tinyfish_delayed": run.delayed, + "tinyfish_last_heartbeat_at": run.last_heartbeat_at.isoformat() if run.last_heartbeat_at else None, + "tinyfish_last_progress_at": run.last_progress_at.isoformat() if run.last_progress_at else None, + } diff --git a/TinyDetective/adapters/seller_page_adapter.py b/TinyDetective/adapters/seller_page_adapter.py new file mode 100644 index 000000000..24586f6bf --- /dev/null +++ b/TinyDetective/adapters/seller_page_adapter.py @@ -0,0 +1,108 @@ +"""TinyFish-backed seller storefront extraction adapter.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from models.case_schemas import SellerProfile +from services.tinyfish_client import TinyFishClient, TinyFishRun + + +class TinyFishSellerPageAdapter: + """Extract seller profile details from a storefront or listing page using TinyFish.""" + + def __init__(self, client: TinyFishClient | None = None) -> None: + self.client = client or TinyFishClient() + + async def extract_profile( + self, + listing_url: str, + marketplace: str, + seller_name: str | None = None, + seller_url: str | None = None, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[SellerProfile, dict[str, Any]]: + target_url = seller_url or listing_url + run = await self.client.run_json( + target_url, + self._goal(listing_url, marketplace, seller_name, seller_url), + on_update=on_update, + ) + result = self._coerce_result_object(run) + result["seller_url"] = result.get("seller_url") or seller_url or target_url + result["seller_name"] = result.get("seller_name") or seller_name + result["marketplace"] = result.get("marketplace") or marketplace + return SellerProfile.model_validate(result), self._raw_output(run) + + async def resume_extract_profile( + self, + listing_url: str, + marketplace: str, + run_id: str, + seller_name: str | None = None, + seller_url: str | None = None, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[SellerProfile, dict[str, Any]]: + target_url = seller_url or listing_url + run = await self.client.wait_for_run( + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + result = self._coerce_result_object(run) + result["seller_url"] = result.get("seller_url") or seller_url or target_url + result["seller_name"] = result.get("seller_name") or seller_name + result["marketplace"] = result.get("marketplace") or marketplace + return SellerProfile.model_validate(result), self._raw_output(run) + + @staticmethod + def _goal( + listing_url: str, + marketplace: str, + seller_name: str | None, + seller_url: str | None, + ) -> str: + return ( + "You are building a seller-enforcement case for a suspicious ecommerce listing. " + f"Primary listing URL: {listing_url!r}. Marketplace: {marketplace!r}. " + f"Known seller name: {seller_name!r}. Known storefront URL: {seller_url!r}. " + "If you start on the listing page, navigate to the seller storefront or profile if visible. " + "Return valid JSON only with this exact shape: " + '{"seller_name":"","seller_id":"","seller_url":"","marketplace":"","rating":0,' + '"rating_count":0,"follower_count":0,"joined_date":"","location":"","badges":[],' + '"profile_text":"","storefront_summary":"","official_store_claims":[],"image_urls":[],' + '"entry_urls":[],"storefront_shard_urls":[],"extraction_confidence":0.0}. ' + "Use null for unknown scalar values and [] for unknown lists. " + "Include any visible seller entry URLs, storefront tabs, category pages, or pagination links in entry_urls or storefront_shard_urls. " + "Do not invent seller metrics or URLs that are not visible." + ) + + @staticmethod + def _coerce_result_object(run: TinyFishRun) -> dict[str, Any]: + result = run.result + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + return json.loads(result) + except json.JSONDecodeError as exc: + raise ValueError(f"Seller profile extraction was not valid JSON: {result}") from exc + raise ValueError(f"Unexpected TinyFish seller profile result: {result!r}") + + @staticmethod + def _raw_output(run: TinyFishRun) -> dict[str, Any]: + return { + "tinyfish_run_id": run.run_id, + "tinyfish_status": run.status, + "tinyfish_result": run.result, + "tinyfish_elapsed_seconds": run.elapsed_seconds, + "tinyfish_delayed": run.delayed, + "tinyfish_last_heartbeat_at": run.last_heartbeat_at.isoformat() if run.last_heartbeat_at else None, + "tinyfish_last_progress_at": run.last_progress_at.isoformat() if run.last_progress_at else None, + } diff --git a/TinyDetective/adapters/source_page_adapter.py b/TinyDetective/adapters/source_page_adapter.py new file mode 100644 index 000000000..9d934e64b --- /dev/null +++ b/TinyDetective/adapters/source_page_adapter.py @@ -0,0 +1,84 @@ +"""TinyFish-backed source page extraction adapter.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from models.schemas import SourceProduct +from services.tinyfish_client import TinyFishClient, TinyFishRun + + +class TinyFishSourcePageAdapter: + """Extract source product details from an official product page using TinyFish.""" + + def __init__(self, client: TinyFishClient | None = None) -> None: + self.client = client or TinyFishClient() + + async def extract_product( + self, + source_url: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[SourceProduct, dict[str, Any]]: + run = await self.client.run_json(source_url, self._goal(), on_update=on_update) + data = self._coerce_result_object(run) + data["source_url"] = source_url + return SourceProduct.model_validate(data), self._raw_output(run) + + async def resume_extract_product( + self, + source_url: str, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[SourceProduct, dict[str, Any]]: + run = await self.client.wait_for_run( + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + data = self._coerce_result_object(run) + data["source_url"] = source_url + return SourceProduct.model_validate(data), self._raw_output(run) + + @staticmethod + def _goal() -> str: + goal = ( + "Visit this official product page and extract structured product data. " + "Return valid JSON only with this exact shape: " + '{"brand":"","product_name":"","category":"","subcategory":"","price":0,' + '"currency":"","color":"","size":"","material":"","model":"","sku":"",' + '"features":[],"description":"","image_urls":[],"extraction_confidence":0.0}. ' + "Use null for unknown scalar values and [] for unknown lists. " + "Do not invent values that are not visible on the page." + ) + return goal + + @staticmethod + def _coerce_result_object(run: TinyFishRun) -> dict[str, Any]: + result = run.result + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + return json.loads(result) + except json.JSONDecodeError as exc: + raise ValueError(f"Source extraction was not valid JSON: {result}") from exc + raise ValueError(f"Unexpected TinyFish extraction result: {result!r}") + + @staticmethod + def _raw_output(run: TinyFishRun) -> dict[str, Any]: + return { + "tinyfish_run_id": run.run_id, + "tinyfish_status": run.status, + "tinyfish_result": run.result, + "tinyfish_elapsed_seconds": run.elapsed_seconds, + "tinyfish_delayed": run.delayed, + "tinyfish_last_heartbeat_at": run.last_heartbeat_at.isoformat() if run.last_heartbeat_at else None, + "tinyfish_last_progress_at": run.last_progress_at.isoformat() if run.last_progress_at else None, + } + diff --git a/TinyDetective/agents/__init__.py b/TinyDetective/agents/__init__.py new file mode 100644 index 000000000..14152ef7b --- /dev/null +++ b/TinyDetective/agents/__init__.py @@ -0,0 +1 @@ +"""Agent modules for the counterfeit research workflow.""" diff --git a/TinyDetective/agents/candidate_discovery_agent.py b/TinyDetective/agents/candidate_discovery_agent.py new file mode 100644 index 000000000..087ae799c --- /dev/null +++ b/TinyDetective/agents/candidate_discovery_agent.py @@ -0,0 +1,162 @@ +"""Candidate discovery agent.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from adapters.comparison_site_adapter import TinyFishComparisonSiteAdapter +from models.schemas import CandidateProduct, SourceProduct +from services.tinyfish_client import TinyFishRun + + +class CandidateDiscoveryAgent: + """Find likely marketplace candidates per comparison site.""" + + DEFAULT_TOP_N = 5 + + def __init__(self, adapter: TinyFishComparisonSiteAdapter | None = None) -> None: + self.adapter = adapter or TinyFishComparisonSiteAdapter() + + async def run( + self, + source_product: SourceProduct, + comparison_sites: list[str], + top_n: int = DEFAULT_TOP_N, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[list[CandidateProduct], list[dict[str, Any]]]: + site_query_pairs = [ + (site, query) + for site in comparison_sites + for query in self.build_search_queries(source_product) + ] + site_results = await asyncio.gather( + *[ + self.adapter.search( + source_product, + site, + search_query=query, + top_n=top_n, + on_update=on_update, + ) + for site, query in site_query_pairs + ] + ) + return self._merge_results(site_query_pairs, site_results) + + async def run_for_site( + self, + source_product: SourceProduct, + comparison_site: str, + search_query: str, + top_n: int = DEFAULT_TOP_N, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[list[CandidateProduct], dict[str, Any]]: + return await self.adapter.search( + source_product, + comparison_site, + search_query=search_query, + top_n=top_n, + on_update=on_update, + ) + + async def resume_for_site( + self, + source_product: SourceProduct, + comparison_site: str, + run_id: str, + search_query: str, + top_n: int = DEFAULT_TOP_N, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[list[CandidateProduct], dict[str, Any]]: + return await self.adapter.resume_search( + source_product, + comparison_site, + run_id, + search_query=search_query, + top_n=top_n, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + + def build_search_queries(self, source_product: SourceProduct) -> list[str]: + brand = self._clean(source_product.brand) + exact_name = self._clean(source_product.product_name) + product_type = self._product_type(source_product) + size = self._clean(source_product.size) + material = self._clean(source_product.material) + color = self._clean(source_product.color) + feature_terms = [self._feature_fragment(feature) for feature in source_product.features] + + queries: list[str] = [] + if brand and product_type: + queries.append(f"{brand} {product_type}") + if brand and material and product_type: + queries.append(f"{brand} {material} {product_type}") + if brand and size and product_type: + queries.append(f"{brand} {size} {product_type}") + if brand and color and product_type: + queries.append(f"{brand} {color} {product_type}") + for feature in feature_terms[:2]: + if brand and product_type and feature: + queries.append(f"{brand} {feature} {product_type}") + if brand and source_product.category: + queries.append(f"{brand} {self._clean(source_product.category)}") + if exact_name: + if brand and not exact_name.startswith(f"{brand} "): + queries.append(f"{brand} {exact_name}") + else: + queries.append(exact_name) + + deduped: list[str] = [] + for query in queries: + normalized = self._clean(query) + if normalized and normalized not in deduped: + deduped.append(normalized) + return deduped[:5] + + @staticmethod + def _merge_results( + site_query_pairs: list[tuple[str, str]], + site_results: list[tuple[list[CandidateProduct], dict[str, Any]]], + ) -> tuple[list[CandidateProduct], list[dict[str, Any]]]: + candidates_by_url: dict[str, CandidateProduct] = {} + raw_outputs: list[dict[str, Any]] = [] + for (site, query), (site_candidates, raw_output) in zip(site_query_pairs, site_results, strict=False): + raw_outputs.append({"comparison_site": site, "search_query": query, **raw_output}) + for candidate in site_candidates: + candidate_url = str(candidate.product_url) + existing = candidates_by_url.get(candidate_url) + if existing is None: + candidates_by_url[candidate_url] = candidate + continue + existing.discovery_queries = list( + dict.fromkeys(existing.discovery_queries + candidate.discovery_queries) + ) + return list(candidates_by_url.values()), raw_outputs + + @staticmethod + def _product_type(source_product: SourceProduct) -> str: + for value in (source_product.subcategory, source_product.category): + cleaned = CandidateDiscoveryAgent._clean(value) + if cleaned: + return cleaned + return "product" + + @staticmethod + def _feature_fragment(feature: str | None) -> str: + cleaned = CandidateDiscoveryAgent._clean(feature) + if not cleaned: + return "" + return " ".join(cleaned.split()[:3]) + + @staticmethod + def _clean(value: str | None) -> str: + if not value: + return "" + return " ".join(value.lower().replace("/", " ").replace("-", " ").split()) diff --git a/TinyDetective/agents/candidate_triage_agent.py b/TinyDetective/agents/candidate_triage_agent.py new file mode 100644 index 000000000..0b89de20b --- /dev/null +++ b/TinyDetective/agents/candidate_triage_agent.py @@ -0,0 +1,180 @@ +"""Candidate triage agent backed by OpenAI with a heuristic fallback.""" + +from __future__ import annotations + +from typing import Any + +from models.schemas import CandidateProduct, CandidateTriageAssessment, SourceProduct +from services.openai_client import OpenAIClient +from services.settings import settings + + +class CandidateTriageAgent: + """Score discovered candidates before deep TinyFish extraction.""" + + def __init__(self, client: OpenAIClient | None = None) -> None: + self.client = client or OpenAIClient() + + async def run( + self, + source_product: SourceProduct, + candidate: CandidateProduct, + ) -> CandidateTriageAssessment: + if not settings.openai_enabled: + return self._heuristic_assessment(source_product, candidate) + + try: + payload = await self.client.run_json( + model=settings.openai_triage_model, + instructions=( + "You triage discovered ecommerce listings before expensive browser extraction. " + "Prioritize candidates that are likely either the same product, a suspicious imitation, " + "or otherwise worth deeper counterfeit analysis. " + "Be conservative about official-store-like listings and low-information results." + ), + input_text=self._prompt(source_product, candidate), + schema_name="candidate_triage_assessment", + schema=self._schema(), + max_output_tokens=400, + ) + return CandidateTriageAssessment.model_validate( + { + **payload, + "source_url": str(source_product.source_url), + "product_url": str(candidate.product_url), + } + ) + except Exception: + return self._heuristic_assessment(source_product, candidate) + + @staticmethod + def _prompt(source_product: SourceProduct, candidate: CandidateProduct) -> str: + return ( + "Official source product:\n" + f"- source_url: {source_product.source_url}\n" + f"- brand: {source_product.brand}\n" + f"- product_name: {source_product.product_name}\n" + f"- category: {source_product.category}\n" + f"- subcategory: {source_product.subcategory}\n" + f"- price: {source_product.price} {source_product.currency}\n" + f"- color: {source_product.color}\n" + f"- size: {source_product.size}\n" + f"- material: {source_product.material}\n" + f"- model: {source_product.model}\n" + f"- sku: {source_product.sku}\n" + f"- features: {source_product.features}\n\n" + "Discovered candidate metadata:\n" + f"- product_url: {candidate.product_url}\n" + f"- marketplace: {candidate.marketplace}\n" + f"- seller_name: {candidate.seller_name}\n" + f"- title: {candidate.title}\n" + f"- price: {candidate.price} {candidate.currency}\n" + f"- brand: {candidate.brand}\n" + f"- color: {candidate.color}\n" + f"- size: {candidate.size}\n" + f"- material: {candidate.material}\n" + f"- model: {candidate.model}\n" + f"- sku: {candidate.sku}\n" + f"- description: {candidate.description}\n" + f"- discovery_queries: {candidate.discovery_queries}\n\n" + "Return a structured triage decision for whether this candidate should be shortlisted for deep browser extraction." + ) + + @staticmethod + def _schema() -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "properties": { + "investigation_priority_score": {"type": "number"}, + "suspicion_score": {"type": "number"}, + "should_shortlist": {"type": "boolean"}, + "rationale": {"type": "string"}, + "suspicious_signals": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": [ + "investigation_priority_score", + "suspicion_score", + "should_shortlist", + "rationale", + "suspicious_signals", + ], + } + + @staticmethod + def _heuristic_assessment( + source_product: SourceProduct, + candidate: CandidateProduct, + ) -> CandidateTriageAssessment: + title_similarity = CandidateTriageAgent._text_overlap( + source_product.product_name, + candidate.title or candidate.description, + ) + brand_match = CandidateTriageAgent._exact_match(source_product.brand, candidate.brand) + price_gap = CandidateTriageAgent._price_gap_ratio(source_product.price, candidate.price) + signals: list[str] = [] + + if price_gap >= 0.35: + signals.append("suspiciously_low_price") + if source_product.brand and candidate.title and source_product.brand.lower() in candidate.title.lower(): + signals.append("brand_mentioned_in_title") + if title_similarity >= 0.45: + signals.append("title_semantic_overlap") + if candidate.seller_name and any( + term in candidate.seller_name.lower() for term in ("official", "flagship", "mall") + ): + signals.append("official_store_like_seller") + + suspicion_score = min( + 1.0, + 0.15 + + (0.28 if price_gap >= 0.35 else 0.0) + + (0.18 if brand_match == 0.0 and candidate.brand else 0.0) + + (0.12 if title_similarity >= 0.45 else 0.0), + ) + priority_score = min( + 1.0, + 0.18 + + (0.38 * title_similarity) + + (0.2 * brand_match) + + (0.18 if price_gap >= 0.35 else 0.0) + - (0.15 if "official_store_like_seller" in signals else 0.0), + ) + should_shortlist = priority_score >= 0.34 or suspicion_score >= 0.32 + rationale = ( + "Heuristic shortlist based on title overlap, brand alignment, and pricing signals." + if should_shortlist + else "Heuristic triage found insufficient overlap or suspicious signals for deep extraction." + ) + return CandidateTriageAssessment( + source_url=source_product.source_url, + product_url=candidate.product_url, + investigation_priority_score=round(priority_score, 2), + suspicion_score=round(suspicion_score, 2), + should_shortlist=should_shortlist, + rationale=rationale, + suspicious_signals=signals, + ) + + @staticmethod + def _text_overlap(left: str | None, right: str | None) -> float: + if not left or not right: + return 0.0 + left_words = set(left.lower().split()) + right_words = set(right.lower().split()) + if not left_words or not right_words: + return 0.0 + return round(len(left_words & right_words) / len(left_words | right_words), 2) + + @staticmethod + def _exact_match(left: str | None, right: str | None) -> float: + return 1.0 if left and right and left.lower() == right.lower() else 0.0 + + @staticmethod + def _price_gap_ratio(source_price: float | None, candidate_price: float | None) -> float: + if not source_price or not candidate_price: + return 0.0 + return round(max(0.0, (source_price - candidate_price) / source_price), 2) diff --git a/TinyDetective/agents/case_draft_agent.py b/TinyDetective/agents/case_draft_agent.py new file mode 100644 index 000000000..7aea66f8e --- /dev/null +++ b/TinyDetective/agents/case_draft_agent.py @@ -0,0 +1,98 @@ +"""Seller-case drafting agent.""" + +from __future__ import annotations + +from models.case_schemas import ( + ActionRequestDraft, + OfficialProductMatch, + SellerCaseEvidenceItem, + SellerProfile, +) +from models.schemas import ComparisonResult, SourceProduct + + +class CaseDraftAgent: + """Draft an analyst-facing seller case with a platform action request.""" + + async def run( + self, + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + suspect_listings: list[ComparisonResult], + evidence: list[SellerCaseEvidenceItem], + official_matches: list[OfficialProductMatch], + ) -> ActionRequestDraft: + high_risk_count = sum(1 for item in suspect_listings if item.counterfeit_risk_score >= 0.7) + medium_risk_count = sum(1 for item in suspect_listings if 0.45 <= item.counterfeit_risk_score < 0.7) + repeated_pattern = high_risk_count + medium_risk_count >= 2 + matched_official_count = sum(1 for item in official_matches if item.official_product_url) + + if high_risk_count >= 2: + recommended_action = "seller suspension review" + elif high_risk_count == 1: + recommended_action = "listing takedown and seller review" + elif medium_risk_count >= 1: + recommended_action = "manual review" + else: + recommended_action = "insufficient evidence" + + violation_type = "suspected counterfeit / trademark misuse" + evidence_references = list( + dict.fromkeys( + [ + str(item.reference_url) + for item in evidence + if item.reference_url + ] + ) + )[:8] + + seller_name = seller_profile.seller_name or selected_listing.candidate_product.seller_name or "Unknown seller" + product_label = source_product.product_name or source_product.model or source_product.brand or "protected product" + risk_line = ( + f"{high_risk_count} high-risk and {medium_risk_count} medium-risk seller listings were identified." + if suspect_listings + else "No additional seller listings were confirmed beyond the selected listing." + ) + reasoning = ( + f"The selected seller storefront for {seller_name} shows repeated product-listing behavior that overlaps with " + f"the protected product {product_label}. {risk_line} " + f"{matched_official_count} seller listing{'s' if matched_official_count != 1 else ''} were also linked back to official brand-site product pages for direct comparison. " + "The attached evidence captures pricing anomalies, copied or overlapping product attributes, and repeated " + "use of the protected brand or product family across the seller inventory." + ) + if not repeated_pattern and recommended_action == "insufficient evidence": + reasoning = ( + f"The seller storefront for {seller_name} was reviewed, but the evidence did not reach a strong enough " + "threshold for a confident enforcement recommendation without manual review." + ) + + summary = ( + f"Seller case prepared for {seller_name} with {len(suspect_listings)} suspect listing" + f"{'' if len(suspect_listings) == 1 else 's'} and {len(evidence)} evidence item" + f"{'' if len(evidence) == 1 else 's'}." + ) + + request_text = ( + f"We request marketplace review of seller {seller_name} for suspected counterfeit or infringing listings " + f"related to {source_product.brand or 'the protected brand'}. " + f"The selected seed listing is {selected_listing.product_url}. " + f"Our review found {len(suspect_listings)} seller listing{'s' if len(suspect_listings) != 1 else ''} " + f"with overlapping product attributes and {matched_official_count} matched official product reference" + f"{'' if matched_official_count == 1 else 's'} on the brand website, together with supporting evidence indicating potential counterfeit or imitation activity. " + "Please review the cited URLs and evidence references and take the appropriate trust-and-safety action." + ) + + confidence = 0.88 if high_risk_count >= 2 else 0.74 if high_risk_count == 1 else 0.58 + + return ActionRequestDraft( + case_title=f"Seller enforcement case for {seller_name}", + summary=summary, + reasoning=reasoning, + suspected_violation_type=violation_type, + recommended_action=recommended_action, + request_text=request_text, + evidence_references=evidence_references, + confidence=confidence, + ) diff --git a/TinyDetective/agents/evidence_agent.py b/TinyDetective/agents/evidence_agent.py new file mode 100644 index 000000000..808e25fbc --- /dev/null +++ b/TinyDetective/agents/evidence_agent.py @@ -0,0 +1,85 @@ +"""Evidence synthesis agent.""" + +from __future__ import annotations + +from models.schemas import ComparisonResult, EvidenceItem, SourceProduct + + +class EvidenceAgent: + """Produce audit-friendly evidence items for each comparison.""" + + async def run( + self, + source_product: SourceProduct, + comparison: ComparisonResult, + ) -> list[EvidenceItem]: + candidate = comparison.candidate_product + evidence: list[EvidenceItem] = [] + evidence.extend( + self._compare_field("brand_match", "brand", source_product.brand, candidate.brand) + ) + evidence.extend( + self._compare_field( + "sku_check", + "sku", + source_product.sku, + candidate.sku, + report_mismatch=False, + ) + ) + evidence.extend(self._compare_field("model_check", "model", source_product.model, candidate.model)) + evidence.extend(self._compare_field("color_check", "color", source_product.color, candidate.color)) + evidence.extend(self._compare_field("size_check", "size", source_product.size, candidate.size)) + if source_product.price and candidate.price: + ratio = (source_product.price - candidate.price) / source_product.price + if ratio >= 0.4: + evidence.append( + EvidenceItem( + type="price_gap", + field="price", + source_value=source_product.price, + candidate_value=candidate.price, + confidence=0.91, + note="Candidate price is materially below the official source price.", + ) + ) + if ( + source_product.description + and candidate.description + and source_product.description[:40].lower() in candidate.description.lower() + ): + evidence.append( + EvidenceItem( + type="copied_description", + field="description", + source_value=source_product.description[:60], + candidate_value=candidate.description[:60], + confidence=0.73, + note="Candidate description appears to reuse source product copy.", + ) + ) + return evidence + + @staticmethod + def _compare_field( + evidence_type: str, + field: str, + source_value: str | None, + candidate_value: str | None, + report_mismatch: bool = True, + ) -> list[EvidenceItem]: + if not source_value and not candidate_value: + return [] + matches = bool(source_value and candidate_value and source_value.lower() == candidate_value.lower()) + if not matches and not report_mismatch: + return [] + return [ + EvidenceItem( + type=evidence_type, + field=field, + source_value=source_value, + candidate_value=candidate_value, + confidence=0.9 if matches else 0.65, + note=f"{field.title()} {'matches' if matches else 'does not match'} between source and candidate.", + ) + ] diff --git a/TinyDetective/agents/official_product_match_agent.py b/TinyDetective/agents/official_product_match_agent.py new file mode 100644 index 000000000..f71e40b83 --- /dev/null +++ b/TinyDetective/agents/official_product_match_agent.py @@ -0,0 +1,81 @@ +"""Official product matching agent for seller-case evidence strengthening.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from adapters.official_product_adapter import TinyFishOfficialProductAdapter +from agents.source_extraction_agent import SourceExtractionAgent +from models.case_schemas import OfficialProductMatch, SellerListing +from models.schemas import SourceProduct +from services.tinyfish_client import TinyFishRun + + +class OfficialProductMatchAgent: + """Find and extract the closest official product page for a seller listing.""" + + def __init__( + self, + adapter: TinyFishOfficialProductAdapter | None = None, + source_agent: SourceExtractionAgent | None = None, + ) -> None: + self.adapter = adapter or TinyFishOfficialProductAdapter() + self.source_agent = source_agent or SourceExtractionAgent() + + async def run( + self, + source_product: SourceProduct, + listing: SellerListing, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[OfficialProductMatch, dict[str, Any]]: + match, discovery_output = await self.adapter.discover_official_product( + source_product, + listing, + on_update=on_update, + ) + enriched_match, extraction_output = await self._extract_official_product(match, source_product) + return enriched_match, { + "discovery_runtime": discovery_output, + "extraction_runtime": extraction_output, + } + + async def resume( + self, + source_product: SourceProduct, + listing: SellerListing, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[OfficialProductMatch, dict[str, Any]]: + match, discovery_output = await self.adapter.resume_discover_official_product( + source_product, + listing, + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + enriched_match, extraction_output = await self._extract_official_product(match, source_product) + return enriched_match, { + "discovery_runtime": discovery_output, + "extraction_runtime": extraction_output, + } + + async def _extract_official_product( + self, + match: OfficialProductMatch, + fallback_source_product: SourceProduct, + ) -> tuple[OfficialProductMatch, dict[str, Any]]: + if not match.official_product_url: + match.official_product = fallback_source_product + return match, {} + if str(match.official_product_url) == str(fallback_source_product.source_url): + match.official_product = fallback_source_product + return match, {} + + official_product, extraction_output = await self.source_agent.run(str(match.official_product_url)) + match.official_product = official_product + return match, extraction_output diff --git a/TinyDetective/agents/product_comparison_agent.py b/TinyDetective/agents/product_comparison_agent.py new file mode 100644 index 000000000..f9100f359 --- /dev/null +++ b/TinyDetective/agents/product_comparison_agent.py @@ -0,0 +1,284 @@ +"""Product comparison agent.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import datetime +from urllib.parse import urlparse + +from adapters.comparison_site_adapter import TinyFishComparisonSiteAdapter +from models.schemas import CandidateProduct, ComparisonResult, SourceProduct +from services.settings import settings +from services.tinyfish_client import TinyFishRun + + +OFFICIAL_STORE_CONFIDENCE_THRESHOLD = 0.75 +OFFICIAL_STORE_TERMS = ( + "official", + "official store", + "flagship", + "authorized", + "authorised", + "authentic", + "mall", +) + + +def counterfeit_risk_score_safe(score: float) -> bool: + """Guard exact-match classification with a conservative risk threshold.""" + return score <= 0.3 + + +class ProductComparisonAgent: + """Compare source and candidate products with explainable heuristics.""" + + def __init__(self, adapter: TinyFishComparisonSiteAdapter | None = None) -> None: + self.adapter = adapter or TinyFishComparisonSiteAdapter() + + async def run( + self, + source_product: SourceProduct, + candidate: CandidateProduct, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[ComparisonResult, dict[str, Any]]: + candidate_full, raw_output = await self._fetch_candidate(candidate, on_update=on_update) + candidate_full.discovery_queries = list(candidate.discovery_queries) + return self._build_result(source_product, candidate_full), raw_output + + async def resume( + self, + source_product: SourceProduct, + candidate: CandidateProduct, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[ComparisonResult, dict[str, Any]]: + candidate_full, raw_output = await self._resume_candidate( + candidate, + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + candidate_full.discovery_queries = list(candidate.discovery_queries) + return self._build_result(source_product, candidate_full), raw_output + + async def _fetch_candidate( + self, + candidate: CandidateProduct, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[CandidateProduct, dict[str, Any]]: + if on_update is None: + return await self.adapter.fetch_candidate_product( + str(candidate.product_url), + candidate.marketplace, + ) + return await self.adapter.fetch_candidate_product( + str(candidate.product_url), + candidate.marketplace, + on_update=on_update, + ) + + async def _resume_candidate( + self, + candidate: CandidateProduct, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[CandidateProduct, dict[str, Any]]: + if on_update is None: + return await self.adapter.resume_candidate_product( + str(candidate.product_url), + candidate.marketplace, + run_id, + started_at=started_at, + last_progress_at=last_progress_at, + ) + return await self.adapter.resume_candidate_product( + str(candidate.product_url), + candidate.marketplace, + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + + def _build_result( + self, + source_product: SourceProduct, + candidate_full: CandidateProduct, + ) -> ComparisonResult: + comparisons = { + "brand": self._eq(source_product.brand, candidate_full.brand), + "title": self._contains(source_product.product_name, candidate_full.title), + "sku": self._eq(source_product.sku, candidate_full.sku), + "model": self._eq(source_product.model, candidate_full.model), + "color": self._eq(source_product.color, candidate_full.color), + "material": self._eq(source_product.material, candidate_full.material), + "size": self._eq(source_product.size, candidate_full.size), + "description": self._description_similarity( + source_product.description, candidate_full.description + ), + } + base_match_score = ( + comparisons["brand"] * 0.25 + + comparisons["title"] * 0.25 + + comparisons["model"] * 0.15 + + comparisons["color"] * 0.05 + + comparisons["material"] * 0.05 + + comparisons["size"] * 0.05 + + comparisons["description"] * 0.10 + ) + sku_bonus = 0.10 if comparisons["sku"] == 1.0 else 0.0 + match_score = round(min(1.0, base_match_score + sku_bonus), 2) + + suspicious_signals: list[str] = [] + price_gap = self._price_gap_ratio(source_product.price, candidate_full.price) + if price_gap >= 0.4: + suspicious_signals.append("suspiciously_low_price") + if comparisons["brand"] < 1.0: + suspicious_signals.append("brand_mismatch") + if comparisons["description"] >= 0.7 and price_gap >= 0.4: + suspicious_signals.append("copied_description_with_discount_pricing") + + counterfeit_risk = round( + min( + 1.0, + 0.2 + + (0.45 if price_gap >= 0.4 else 0.0) + + (0.15 if comparisons["brand"] < 1.0 else 0.0) + + (0.1 if comparisons["description"] >= 0.7 else 0.0), + ), + 2, + ) + is_exact_match = ( + comparisons["brand"] == 1.0 + and comparisons["title"] >= 0.9 + and comparisons["model"] == 1.0 + and counterfeit_risk_score_safe(counterfeit_risk) + ) + + official_store_confidence, official_store_signals = self._official_store_confidence( + source_product, + candidate_full, + comparisons["brand"], + ) + is_official_store = official_store_confidence >= OFFICIAL_STORE_CONFIDENCE_THRESHOLD + reason = self._build_reason(match_score, counterfeit_risk, suspicious_signals) + if is_official_store: + reason = "High-confidence official store listing detected; excluded from suspicious results." + + return ComparisonResult( + source_url=source_product.source_url, + product_url=candidate_full.product_url, + marketplace=candidate_full.marketplace, + match_score=match_score, + is_exact_match=is_exact_match, + is_official_store=is_official_store, + official_store_confidence=official_store_confidence, + official_store_signals=official_store_signals, + counterfeit_risk_score=counterfeit_risk, + suspicious_signals=suspicious_signals, + reason=reason, + candidate_product=candidate_full, + ) + + @staticmethod + def _eq(left: str | None, right: str | None) -> float: + return 1.0 if left and right and left.lower() == right.lower() else 0.0 + + @staticmethod + def _contains(left: str | None, right: str | None) -> float: + if not left or not right: + return 0.0 + left_norm = left.lower() + right_norm = right.lower() + if left_norm == right_norm: + return 1.0 + if left_norm in right_norm or right_norm in left_norm: + return 0.8 + overlap = len(set(left_norm.split()) & set(right_norm.split())) + return min(0.7, overlap / max(len(left_norm.split()), 1)) + + @staticmethod + def _description_similarity(left: str | None, right: str | None) -> float: + if not left or not right: + return 0.0 + left_words = set(left.lower().split()) + right_words = set(right.lower().split()) + if not left_words or not right_words: + return 0.0 + return round(len(left_words & right_words) / len(left_words | right_words), 2) + + @staticmethod + def _price_gap_ratio(source_price: float | None, candidate_price: float | None) -> float: + if not source_price or not candidate_price: + return 0.0 + return round(max(0.0, (source_price - candidate_price) / source_price), 2) + + @staticmethod + def _official_store_confidence( + source_product: SourceProduct, + candidate_product: CandidateProduct, + brand_match_score: float, + ) -> tuple[float, list[str]]: + signals: list[str] = [] + confidence = 0.0 + source_host = ProductComparisonAgent._host(str(source_product.source_url)) + candidate_host = ProductComparisonAgent._host(str(candidate_product.product_url)) + brand_host = ProductComparisonAgent._host(settings.brand_landing_page_url) + seller_name = ProductComparisonAgent._normalize(candidate_product.seller_name) + source_brand = ProductComparisonAgent._normalize(source_product.brand) + + if candidate_host and (candidate_host == source_host or (brand_host and candidate_host == brand_host)): + signals.append("listing_host_matches_official_brand_host") + return 1.0, signals + + if brand_match_score == 1.0: + confidence += 0.2 + signals.append("candidate_brand_matches_source_brand") + + brand_tokens = [token for token in source_brand.split() if token] + if seller_name and brand_tokens: + if all(token in seller_name for token in brand_tokens): + confidence += 0.35 + signals.append("seller_name_matches_source_brand") + elif any(token in seller_name for token in brand_tokens): + confidence += 0.15 + signals.append("seller_name_partially_matches_source_brand") + + if seller_name and any(term in seller_name for term in OFFICIAL_STORE_TERMS): + confidence += 0.35 + signals.append("seller_name_contains_official_store_terms") + + return round(min(1.0, confidence), 2), signals + + @staticmethod + def _normalize(value: str | None) -> str: + if not value: + return "" + return " ".join(value.lower().replace("-", " ").replace("_", " ").split()) + + @staticmethod + def _host(value: str | None) -> str: + if not value: + return "" + return urlparse(value).netloc.lower().replace("www.", "") + + @staticmethod + def _build_reason( + match_score: float, + counterfeit_risk: float, + suspicious_signals: list[str], + ) -> str: + if match_score >= 0.85 and counterfeit_risk < 0.35: + return "Strong structured attribute match with limited counterfeit signals." + if suspicious_signals: + return ( + "Candidate shares some product attributes but shows risk indicators: " + + ", ".join(suspicious_signals) + + "." + ) + return "Candidate is directionally similar but lacks enough aligned attributes." diff --git a/TinyDetective/agents/ranking_agent.py b/TinyDetective/agents/ranking_agent.py new file mode 100644 index 000000000..f51fd971a --- /dev/null +++ b/TinyDetective/agents/ranking_agent.py @@ -0,0 +1,24 @@ +"""Ranking agent.""" + +from __future__ import annotations + +from models.schemas import ComparisonResult + + +class RankingAgent: + """Rank candidates by counterfeit risk and keep the strongest set.""" + + TOP_MATCH_LIMIT = 5 + + async def run(self, comparisons: list[ComparisonResult]) -> list[ComparisonResult]: + ranked = sorted( + comparisons, + key=lambda item: ( + item.counterfeit_risk_score, + item.match_score, + 1 if item.is_exact_match else 0, + ), + reverse=True, + ) + return ranked[: self.TOP_MATCH_LIMIT] + diff --git a/TinyDetective/agents/reasoning_enrichment_agent.py b/TinyDetective/agents/reasoning_enrichment_agent.py new file mode 100644 index 000000000..9b439b785 --- /dev/null +++ b/TinyDetective/agents/reasoning_enrichment_agent.py @@ -0,0 +1,190 @@ +"""OpenAI-backed comparison reasoning enrichment.""" + +from __future__ import annotations + +from typing import Any + +from models.schemas import ( + ComparisonReasoningEnrichment, + ComparisonResult, + SourceProduct, +) +from services.openai_client import OpenAIClient +from services.settings import settings + + +class ReasoningEnrichmentAgent: + """Refine comparison rationale with a bounded OpenAI pass.""" + + MAX_RISK_ADJUSTMENT = 0.12 + MIN_RISK_ADJUSTMENT = -0.08 + MAX_MATCH_ADJUSTMENT = 0.08 + MIN_MATCH_ADJUSTMENT = -0.08 + + def __init__(self, client: OpenAIClient | None = None) -> None: + self.client = client or OpenAIClient() + + async def run( + self, + source_product: SourceProduct, + comparison: ComparisonResult, + ) -> ComparisonReasoningEnrichment: + if not settings.openai_enabled: + return self._noop_enrichment(source_product, comparison) + + try: + payload = await self.client.run_json( + model=settings.openai_reasoning_model, + instructions=( + "You refine structured counterfeit-comparison results after deterministic extraction and scoring. " + "Do not invent facts. Use only the supplied structured product data, scores, signals, and evidence. " + "You may add concise reasoning notes and suggest small bounded score adjustments only when the evidence strongly supports it. " + "Never suggest official-store classification changes or exact-match overrides." + ), + input_text=self._prompt(source_product, comparison), + schema_name="comparison_reasoning_enrichment", + schema=self._schema(), + max_output_tokens=500, + ) + enrichment = ComparisonReasoningEnrichment.model_validate( + { + **payload, + "source_url": str(source_product.source_url), + "product_url": str(comparison.product_url), + } + ) + enrichment.risk_adjustment = self._clamp( + enrichment.risk_adjustment, + self.MIN_RISK_ADJUSTMENT, + self.MAX_RISK_ADJUSTMENT, + ) + enrichment.match_adjustment = self._clamp( + enrichment.match_adjustment, + self.MIN_MATCH_ADJUSTMENT, + self.MAX_MATCH_ADJUSTMENT, + ) + return enrichment + except Exception: + return self._noop_enrichment(source_product, comparison) + + def apply( + self, + comparison: ComparisonResult, + enrichment: ComparisonReasoningEnrichment, + ) -> ComparisonResult: + comparison.reason = enrichment.enriched_reason or comparison.reason + comparison.reasoning_notes = list( + dict.fromkeys(comparison.reasoning_notes + enrichment.reasoning_notes) + ) + comparison.suspicious_signals = list( + dict.fromkeys(comparison.suspicious_signals + enrichment.additional_suspicious_signals) + ) + comparison.counterfeit_risk_score = round( + self._clamp( + comparison.counterfeit_risk_score + enrichment.risk_adjustment, + 0.0, + 1.0, + ), + 2, + ) + comparison.match_score = round( + self._clamp( + comparison.match_score + enrichment.match_adjustment, + 0.0, + 1.0, + ), + 2, + ) + comparison.reasoning_enrichment_source = "openai" if settings.openai_enabled else "deterministic" + return comparison + + @staticmethod + def _prompt(source_product: SourceProduct, comparison: ComparisonResult) -> str: + candidate = comparison.candidate_product + evidence_lines = [ + f"- {item.field}: {item.note} | source={item.source_value} | candidate={item.candidate_value}" + for item in comparison.evidence + ] + return ( + "Official source product:\n" + f"- source_url: {source_product.source_url}\n" + f"- brand: {source_product.brand}\n" + f"- product_name: {source_product.product_name}\n" + f"- category: {source_product.category}\n" + f"- subcategory: {source_product.subcategory}\n" + f"- price: {source_product.price} {source_product.currency}\n" + f"- color: {source_product.color}\n" + f"- size: {source_product.size}\n" + f"- material: {source_product.material}\n" + f"- model: {source_product.model}\n" + f"- sku: {source_product.sku}\n" + f"- description: {source_product.description}\n\n" + "Extracted candidate listing:\n" + f"- product_url: {comparison.product_url}\n" + f"- marketplace: {comparison.marketplace}\n" + f"- seller_name: {candidate.seller_name}\n" + f"- title: {candidate.title}\n" + f"- price: {candidate.price} {candidate.currency}\n" + f"- brand: {candidate.brand}\n" + f"- color: {candidate.color}\n" + f"- size: {candidate.size}\n" + f"- material: {candidate.material}\n" + f"- model: {candidate.model}\n" + f"- sku: {candidate.sku}\n" + f"- description: {candidate.description}\n\n" + "Current deterministic comparison output:\n" + f"- match_score: {comparison.match_score}\n" + f"- counterfeit_risk_score: {comparison.counterfeit_risk_score}\n" + f"- is_exact_match: {comparison.is_exact_match}\n" + f"- is_official_store: {comparison.is_official_store}\n" + f"- suspicious_signals: {comparison.suspicious_signals}\n" + f"- reason: {comparison.reason}\n\n" + "Structured evidence:\n" + + ("\n".join(evidence_lines) if evidence_lines else "- none\n") + ) + + @staticmethod + def _schema() -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "properties": { + "enriched_reason": {"type": "string"}, + "reasoning_notes": { + "type": "array", + "items": {"type": "string"}, + }, + "additional_suspicious_signals": { + "type": "array", + "items": {"type": "string"}, + }, + "risk_adjustment": {"type": "number"}, + "match_adjustment": {"type": "number"}, + }, + "required": [ + "enriched_reason", + "reasoning_notes", + "additional_suspicious_signals", + "risk_adjustment", + "match_adjustment", + ], + } + + @staticmethod + def _noop_enrichment( + source_product: SourceProduct, + comparison: ComparisonResult, + ) -> ComparisonReasoningEnrichment: + return ComparisonReasoningEnrichment( + source_url=source_product.source_url, + product_url=comparison.product_url, + enriched_reason=comparison.reason, + reasoning_notes=[], + additional_suspicious_signals=[], + risk_adjustment=0.0, + match_adjustment=0.0, + ) + + @staticmethod + def _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(upper, float(value))) diff --git a/TinyDetective/agents/research_summary_agent.py b/TinyDetective/agents/research_summary_agent.py new file mode 100644 index 000000000..a034cf206 --- /dev/null +++ b/TinyDetective/agents/research_summary_agent.py @@ -0,0 +1,45 @@ +"""Research summary agent.""" + +from __future__ import annotations + +from models.schemas import ComparisonResult, SourceProduct + + +class ResearchSummaryAgent: + """Generate a concise investigation summary per source product.""" + + async def run( + self, + source_product: SourceProduct | None, + top_matches: list[ComparisonResult], + excluded_official_store_count: int = 0, + error: str | None = None, + ) -> str: + if error: + return f"Source extraction failed: {error}" + if source_product is None: + return "No source product could be extracted." + if not top_matches: + if excluded_official_store_count > 0: + return ( + "Only high-confidence official-store listings were found and excluded " + "from suspicious results." + ) + return "No strong candidate was found across the selected comparison sites." + best = top_matches[0] + if best.is_exact_match and best.counterfeit_risk_score < 0.3: + return ( + "Results suggest a legitimate matching listing with strong structured overlap " + "and limited counterfeit indicators." + ) + if best.counterfeit_risk_score >= 0.6: + return ( + "Results suggest a likely counterfeit or imitation listing due to pricing and " + "attribute inconsistencies." + ) + if best.match_score >= 0.55: + return ( + "Results suggest a possible unauthorized reseller or similar listing, but " + "evidence is not conclusive." + ) + return "There is insufficient evidence to classify the candidate listings confidently." diff --git a/TinyDetective/agents/seller_evidence_agent.py b/TinyDetective/agents/seller_evidence_agent.py new file mode 100644 index 000000000..f449867d4 --- /dev/null +++ b/TinyDetective/agents/seller_evidence_agent.py @@ -0,0 +1,121 @@ +"""Seller-case evidence synthesis agent.""" + +from __future__ import annotations + +from statistics import mean + +from models.case_schemas import OfficialProductMatch, SellerCaseEvidenceItem, SellerProfile +from models.schemas import ComparisonResult, SourceProduct + + +class SellerEvidenceAgent: + """Convert seller research outputs into case-friendly evidence objects.""" + + async def run( + self, + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + suspect_listings: list[ComparisonResult], + official_matches: list[OfficialProductMatch], + ) -> list[SellerCaseEvidenceItem]: + evidence: list[SellerCaseEvidenceItem] = [] + official_matches_by_url = {str(item.product_url): item for item in official_matches} + + if seller_profile.official_store_claims: + evidence.append( + SellerCaseEvidenceItem( + type="official_store_mimicry", + title="Storefront uses official-store language", + note=( + "The seller storefront presents official or authorized-store claims that should be " + "reviewed against the brand's actual authorized channels." + ), + reference_url=seller_profile.seller_url, + candidate_value=", ".join(seller_profile.official_store_claims), + confidence=0.67, + subject=seller_profile.seller_name, + supporting_signals=list(seller_profile.official_store_claims), + ) + ) + + risk_listings = [listing for listing in suspect_listings if listing.counterfeit_risk_score >= 0.55] + if len(risk_listings) >= 2: + evidence.append( + SellerCaseEvidenceItem( + type="repeat_product_family_pattern", + title="Multiple suspicious listings from the same seller", + note=( + f"The seller storefront surfaced {len(risk_listings)} listings with elevated counterfeit-risk " + "scores against the same protected brand or product family." + ), + reference_url=seller_profile.seller_url or selected_listing.product_url, + candidate_value=len(risk_listings), + confidence=0.84, + subject=seller_profile.seller_name, + supporting_signals=["repeat_suspicious_listing"], + ) + ) + + discounted = [ + listing for listing in suspect_listings if "suspiciously_low_price" in listing.suspicious_signals + ] + discounted_prices = [ + listing.candidate_product.price + for listing in discounted + if listing.candidate_product.price is not None + ] + if discounted and discounted_prices: + avg_price = mean(discounted_prices) + evidence.append( + SellerCaseEvidenceItem( + type="suspicious_price_pattern", + title="Seller shows repeated below-market pricing", + note=( + "One or more seller listings are priced materially below the official source product, " + "which is a common counterfeit or imitation signal." + ), + reference_url=discounted[0].product_url, + source_value=source_product.price, + candidate_value=round(avg_price, 2), + confidence=0.89, + subject=seller_profile.seller_name, + supporting_signals=["suspiciously_low_price"], + ) + ) + + for listing in suspect_listings[:8]: + official_match = official_matches_by_url.get(str(listing.product_url)) + if official_match and official_match.official_product_url: + evidence.append( + SellerCaseEvidenceItem( + type="official_product_reference", + title=f"{listing.marketplace}: Official product reference located", + note=( + official_match.rationale + or "A corresponding official product page was located on the brand website for direct comparison." + ), + reference_url=official_match.official_product_url, + source_value=str(official_match.official_product_url), + candidate_value=str(listing.product_url), + confidence=official_match.match_confidence, + subject=listing.candidate_product.title or listing.product_url, + supporting_signals=["official_product_match"], + ) + ) + for item in listing.evidence[:5]: + evidence.append( + SellerCaseEvidenceItem( + type=item.type, + title=f"{listing.marketplace}: {item.field}", + note=item.note, + reference_url=listing.product_url, + source_value=item.source_value, + candidate_value=item.candidate_value, + confidence=item.confidence, + subject=listing.candidate_product.title or listing.product_url, + supporting_signals=list(listing.suspicious_signals), + ) + ) + + return evidence diff --git a/TinyDetective/agents/seller_listing_analysis_agent.py b/TinyDetective/agents/seller_listing_analysis_agent.py new file mode 100644 index 000000000..5e834310a --- /dev/null +++ b/TinyDetective/agents/seller_listing_analysis_agent.py @@ -0,0 +1,70 @@ +"""Seller listing analysis agent.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from agents.product_comparison_agent import ProductComparisonAgent +from models.case_schemas import SellerListing +from models.schemas import CandidateProduct, ComparisonResult, SourceProduct +from services.tinyfish_client import TinyFishRun + + +class SellerListingAnalysisAgent: + """Deep-dive seller listings and compare them to the protected source product.""" + + def __init__(self, comparison_agent: ProductComparisonAgent | None = None) -> None: + self.comparison_agent = comparison_agent or ProductComparisonAgent() + + async def run( + self, + source_product: SourceProduct, + listing: SellerListing, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[ComparisonResult, dict[str, Any]]: + return await self.comparison_agent.run( + source_product, + self._candidate_from_listing(listing), + on_update=on_update, + ) + + async def resume( + self, + source_product: SourceProduct, + listing: SellerListing, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[ComparisonResult, dict[str, Any]]: + return await self.comparison_agent.resume( + source_product, + self._candidate_from_listing(listing), + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + + @staticmethod + def _candidate_from_listing(listing: SellerListing) -> CandidateProduct: + return CandidateProduct( + product_url=listing.product_url, + marketplace=listing.marketplace, + seller_name=listing.seller_name, + seller_store_url=listing.seller_store_url, + seller_id=listing.seller_id, + title=listing.title, + price=listing.price, + currency=listing.currency, + brand=listing.brand, + color=listing.color, + size=listing.size, + material=listing.material, + model=listing.model, + sku=listing.sku, + description=listing.description, + image_urls=list(listing.image_urls), + ) diff --git a/TinyDetective/agents/seller_listing_discovery_agent.py b/TinyDetective/agents/seller_listing_discovery_agent.py new file mode 100644 index 000000000..7b38f56eb --- /dev/null +++ b/TinyDetective/agents/seller_listing_discovery_agent.py @@ -0,0 +1,61 @@ +"""Seller listing discovery agent.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from adapters.seller_listing_adapter import TinyFishSellerListingAdapter +from models.case_schemas import SellerListing, SellerProfile +from models.schemas import ComparisonResult, SourceProduct +from services.tinyfish_client import TinyFishRun + + +class SellerListingDiscoveryAgent: + """Discover a seller's most relevant listings for case-building.""" + + def __init__(self, adapter: TinyFishSellerListingAdapter | None = None) -> None: + self.adapter = adapter or TinyFishSellerListingAdapter() + + async def run( + self, + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + entry_url: str, + top_n: int = 8, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[list[SellerListing], dict[str, Any]]: + return await self.adapter.discover_listings( + source_product, + seller_profile, + selected_listing, + entry_url, + top_n, + on_update=on_update, + ) + + async def resume( + self, + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + entry_url: str, + run_id: str, + top_n: int = 8, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[list[SellerListing], dict[str, Any]]: + return await self.adapter.resume_discover_listings( + source_product, + seller_profile, + selected_listing, + entry_url, + run_id, + top_n, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) diff --git a/TinyDetective/agents/seller_listing_triage_agent.py b/TinyDetective/agents/seller_listing_triage_agent.py new file mode 100644 index 000000000..19a40e903 --- /dev/null +++ b/TinyDetective/agents/seller_listing_triage_agent.py @@ -0,0 +1,195 @@ +"""Seller listing triage agent backed by OpenAI with a heuristic fallback.""" + +from __future__ import annotations + +from models.case_schemas import SellerListing, SellerListingTriageAssessment, SellerProfile +from models.schemas import ComparisonResult, SourceProduct +from services.openai_client import OpenAIClient +from services.settings import settings + + +class SellerListingTriageAgent: + """Shortlist seller listings before expensive official matching and TinyFish extraction.""" + + def __init__(self, client: OpenAIClient | None = None) -> None: + self.client = client or OpenAIClient() + + async def run( + self, + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + listing: SellerListing, + ) -> SellerListingTriageAssessment: + if not settings.openai_enabled: + return self._heuristic_assessment(source_product, selected_listing, listing) + + try: + payload = await self.client.run_json( + model=settings.openai_triage_model, + instructions=( + "You triage seller-storefront listings before expensive official-site matching and deep browser extraction. " + "Prioritize listings that likely belong to the protected brand, the same product family, or a suspiciously similar variant. " + "Be conservative but preserve recall for repeat-seller patterns that strengthen a counterfeit case." + ), + input_text=self._prompt(source_product, seller_profile, selected_listing, listing), + schema_name="seller_listing_triage_assessment", + schema=self._schema(), + max_output_tokens=400, + ) + return SellerListingTriageAssessment.model_validate( + { + **payload, + "product_url": str(listing.product_url), + } + ) + except Exception: + return self._heuristic_assessment(source_product, selected_listing, listing) + + @staticmethod + def _prompt( + source_product: SourceProduct, + seller_profile: SellerProfile, + selected_listing: ComparisonResult, + listing: SellerListing, + ) -> str: + return ( + "Protected official product:\n" + f"- source_url: {source_product.source_url}\n" + f"- brand: {source_product.brand}\n" + f"- product_name: {source_product.product_name}\n" + f"- category: {source_product.category}\n" + f"- subcategory: {source_product.subcategory}\n" + f"- model: {source_product.model}\n" + f"- sku: {source_product.sku}\n" + f"- color: {source_product.color}\n" + f"- material: {source_product.material}\n" + f"- price: {source_product.price} {source_product.currency}\n" + f"- features: {source_product.features}\n\n" + "Seed suspicious listing:\n" + f"- product_url: {selected_listing.product_url}\n" + f"- title: {selected_listing.candidate_product.title}\n" + f"- seller_name: {selected_listing.candidate_product.seller_name}\n" + f"- risk_score: {selected_listing.counterfeit_risk_score}\n\n" + "Seller storefront context:\n" + f"- seller_name: {seller_profile.seller_name}\n" + f"- seller_url: {seller_profile.seller_url}\n" + f"- badges: {seller_profile.badges}\n" + f"- official_store_claims: {seller_profile.official_store_claims}\n\n" + "Discovered seller listing:\n" + f"- product_url: {listing.product_url}\n" + f"- title: {listing.title}\n" + f"- price: {listing.price} {listing.currency}\n" + f"- brand: {listing.brand}\n" + f"- color: {listing.color}\n" + f"- size: {listing.size}\n" + f"- material: {listing.material}\n" + f"- model: {listing.model}\n" + f"- sku: {listing.sku}\n" + f"- description: {listing.description}\n" + f"- discovery_source: {listing.discovery_source}\n\n" + "Return a structured shortlist decision for whether this seller listing deserves official-site matching and deep extraction." + ) + + @staticmethod + def _schema() -> dict[str, object]: + return { + "type": "object", + "additionalProperties": False, + "properties": { + "investigation_priority_score": {"type": "number"}, + "suspicion_score": {"type": "number"}, + "should_shortlist": {"type": "boolean"}, + "rationale": {"type": "string"}, + "suspicious_signals": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": [ + "investigation_priority_score", + "suspicion_score", + "should_shortlist", + "rationale", + "suspicious_signals", + ], + } + + @staticmethod + def _heuristic_assessment( + source_product: SourceProduct, + selected_listing: ComparisonResult, + listing: SellerListing, + ) -> SellerListingTriageAssessment: + title_similarity = SellerListingTriageAgent._text_overlap( + source_product.product_name, + listing.title or listing.description, + ) + brand_match = SellerListingTriageAgent._exact_match(source_product.brand, listing.brand) + model_match = SellerListingTriageAgent._exact_match(source_product.model, listing.model) + price_gap = SellerListingTriageAgent._price_gap_ratio(source_product.price, listing.price) + seed_title_overlap = SellerListingTriageAgent._text_overlap( + selected_listing.candidate_product.title, + listing.title or listing.description, + ) + + signals: list[str] = [] + if price_gap >= 0.35: + signals.append("suspiciously_low_price") + if title_similarity >= 0.4: + signals.append("seller_listing_title_overlap") + if seed_title_overlap >= 0.4: + signals.append("seed_listing_family_overlap") + if listing.brand and source_product.brand and listing.brand.lower() != source_product.brand.lower(): + signals.append("brand_mismatch") + + suspicion_score = min( + 1.0, + 0.18 + + (0.24 if price_gap >= 0.35 else 0.0) + + (0.14 if "brand_mismatch" in signals else 0.0) + + (0.14 if seed_title_overlap >= 0.4 else 0.0), + ) + priority_score = min( + 1.0, + 0.2 + + (0.3 * title_similarity) + + (0.18 * seed_title_overlap) + + (0.18 * brand_match) + + (0.12 * model_match) + + (0.12 if price_gap >= 0.35 else 0.0), + ) + should_shortlist = priority_score >= 0.34 or suspicion_score >= 0.32 + rationale = ( + "Heuristic shortlist based on seller-listing overlap with the protected product family." + if should_shortlist + else "Heuristic triage found weak overlap with the protected product family." + ) + return SellerListingTriageAssessment( + product_url=listing.product_url, + investigation_priority_score=round(priority_score, 2), + suspicion_score=round(suspicion_score, 2), + should_shortlist=should_shortlist, + rationale=rationale, + suspicious_signals=signals, + ) + + @staticmethod + def _text_overlap(left: str | None, right: str | None) -> float: + if not left or not right: + return 0.0 + left_words = set(left.lower().split()) + right_words = set(right.lower().split()) + if not left_words or not right_words: + return 0.0 + return round(len(left_words & right_words) / len(left_words | right_words), 2) + + @staticmethod + def _exact_match(left: str | None, right: str | None) -> float: + return 1.0 if left and right and left.lower() == right.lower() else 0.0 + + @staticmethod + def _price_gap_ratio(source_price: float | None, candidate_price: float | None) -> float: + if not source_price or not candidate_price: + return 0.0 + return round(max(0.0, (source_price - candidate_price) / source_price), 2) diff --git a/TinyDetective/agents/seller_profile_agent.py b/TinyDetective/agents/seller_profile_agent.py new file mode 100644 index 000000000..54cc9f071 --- /dev/null +++ b/TinyDetective/agents/seller_profile_agent.py @@ -0,0 +1,56 @@ +"""Seller profile agent.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from adapters.seller_page_adapter import TinyFishSellerPageAdapter +from models.case_schemas import SellerProfile +from services.tinyfish_client import TinyFishRun + + +class SellerProfileAgent: + """Inspect a seller storefront and normalize profile metadata.""" + + def __init__(self, adapter: TinyFishSellerPageAdapter | None = None) -> None: + self.adapter = adapter or TinyFishSellerPageAdapter() + + async def run( + self, + listing_url: str, + marketplace: str, + seller_name: str | None = None, + seller_url: str | None = None, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[SellerProfile, dict[str, Any]]: + return await self.adapter.extract_profile( + listing_url, + marketplace, + seller_name=seller_name, + seller_url=seller_url, + on_update=on_update, + ) + + async def resume( + self, + listing_url: str, + marketplace: str, + run_id: str, + seller_name: str | None = None, + seller_url: str | None = None, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[SellerProfile, dict[str, Any]]: + return await self.adapter.resume_extract_profile( + listing_url, + marketplace, + run_id, + seller_name=seller_name, + seller_url=seller_url, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) diff --git a/TinyDetective/agents/source_extraction_agent.py b/TinyDetective/agents/source_extraction_agent.py new file mode 100644 index 000000000..13866bcad --- /dev/null +++ b/TinyDetective/agents/source_extraction_agent.py @@ -0,0 +1,51 @@ +"""Source extraction agent.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from adapters.source_page_adapter import TinyFishSourcePageAdapter +from models.schemas import SourceProduct +from services.tinyfish_client import TinyFishRun + + +class SourceExtractionAgent: + """Extract normalized source product details from an official URL.""" + + def __init__(self, adapter: TinyFishSourcePageAdapter | None = None) -> None: + self.adapter = adapter or TinyFishSourcePageAdapter() + + async def run( + self, + source_url: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + ) -> tuple[SourceProduct, dict[str, Any]]: + if on_update is None: + return await self.adapter.extract_product(source_url) + return await self.adapter.extract_product(source_url, on_update=on_update) + + async def resume( + self, + source_url: str, + run_id: str, + on_update: Callable[[TinyFishRun], Awaitable[None] | None] | None = None, + started_at: datetime | None = None, + last_progress_at: datetime | None = None, + ) -> tuple[SourceProduct, dict[str, Any]]: + if on_update is None: + return await self.adapter.resume_extract_product( + source_url, + run_id, + started_at=started_at, + last_progress_at=last_progress_at, + ) + return await self.adapter.resume_extract_product( + source_url, + run_id, + on_update=on_update, + started_at=started_at, + last_progress_at=last_progress_at, + ) + diff --git a/TinyDetective/backend/__init__.py b/TinyDetective/backend/__init__.py new file mode 100644 index 000000000..cc5e73771 --- /dev/null +++ b/TinyDetective/backend/__init__.py @@ -0,0 +1 @@ +"""Backend application package.""" diff --git a/TinyDetective/backend/__main__.py b/TinyDetective/backend/__main__.py new file mode 100644 index 000000000..6e93dec4c --- /dev/null +++ b/TinyDetective/backend/__main__.py @@ -0,0 +1,9 @@ +"""Run the TinyDetective backend with `python -m backend`.""" + +from __future__ import annotations + + +if __name__ == "__main__": + from backend.main import run as main + + main() diff --git a/TinyDetective/backend/main.py b/TinyDetective/backend/main.py new file mode 100644 index 000000000..c97be6e51 --- /dev/null +++ b/TinyDetective/backend/main.py @@ -0,0 +1,159 @@ +"""FastAPI entrypoint for the counterfeit research MVP.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +if __package__ in {None, ""}: + PROJECT_ROOT = Path(__file__).resolve().parent.parent + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +import uvicorn +from fastapi import FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from models.case_schemas import ( + SellerCaseCreateRequest, + SellerCaseListItem, + SellerCaseResponse, +) +from models.schemas import ( + InvestigationCreateRequest, + InvestigationListItem, + InvestigationResponse, +) +from services.investigation_orchestrator import InvestigationOrchestrator +from services.investigation_store import InvestigationStore +from services.logging_config import LOG_PATH, configure_logging +from services.seller_case_orchestrator import SellerCaseOrchestrator +from services.settings import settings + + +BASE_DIR = Path(__file__).resolve().parent.parent +FRONTEND_DIR = BASE_DIR / "frontend" +logger = configure_logging() + +app = FastAPI( + title="TinyDetective Counterfeit Research MVP", + version="0.1.0", + description="Agent-based counterfeit investigation workflow scaffold.", +) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +store = InvestigationStore() +orchestrator = InvestigationOrchestrator(store=store) +seller_case_orchestrator = SellerCaseOrchestrator(store=store) + +app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") + + +async def recover_unfinished_investigations() -> None: + for investigation in await store.list_active(): + asyncio.create_task(orchestrator.run_investigation(investigation.investigation_id)) + + +async def recover_unfinished_cases() -> None: + for seller_case in await store.list_active_cases(): + asyncio.create_task(seller_case_orchestrator.run_case(seller_case.case_id)) + + +@app.on_event("startup") +async def startup() -> None: + await recover_unfinished_investigations() + await recover_unfinished_cases() + + +@app.get("/", include_in_schema=False) +async def index() -> FileResponse: + return FileResponse(FRONTEND_DIR / "index.html") + + +@app.get("/favicon.ico", include_in_schema=False) +async def favicon() -> FileResponse: + return FileResponse(FRONTEND_DIR / "favicon.svg", media_type="image/svg+xml") + + +@app.get("/health") +async def health() -> dict[str, str]: + return { + "status": "ok", + "tinyfish_enabled": "true" if settings.tinyfish_enabled else "false", + "openai_enabled": "true" if settings.openai_enabled else "false", + } + + +@app.get("/config") +async def config() -> dict[str, object]: + return { + "brand_landing_page_url": settings.brand_landing_page_url, + "ecommerce_store_urls": settings.ecommerce_store_urls, + "tinyfish_browser_profile": settings.tinyfish_browser_profile, + "openai_enabled": settings.openai_enabled, + "openai_triage_model": settings.openai_triage_model, + "openai_reasoning_model": settings.openai_reasoning_model, + "openai_shortlist_limit": settings.openai_shortlist_limit, + "log_path": str(LOG_PATH), + } + + +@app.get("/investigations", response_model=list[InvestigationListItem]) +async def list_investigations(limit: int = Query(default=12, ge=1, le=100)) -> list[InvestigationListItem]: + return await store.list_recent(limit=limit) + + +@app.post("/investigate", response_model=InvestigationResponse) +async def investigate(payload: InvestigationCreateRequest) -> InvestigationResponse: + investigation = await store.create(payload) + logger.info("Investigation queued: %s", investigation.investigation_id) + asyncio.create_task(orchestrator.run_investigation(investigation.investigation_id)) + return investigation + + +@app.get("/investigation/{investigation_id}", response_model=InvestigationResponse) +async def get_investigation(investigation_id: str) -> InvestigationResponse: + investigation = await store.get(investigation_id) + if investigation is None: + raise HTTPException(status_code=404, detail="Investigation not found") + return investigation + + +@app.get("/cases", response_model=list[SellerCaseListItem]) +async def list_cases(limit: int = Query(default=12, ge=1, le=100)) -> list[SellerCaseListItem]: + return await store.list_recent_cases(limit=limit) + + +@app.post("/cases", response_model=SellerCaseResponse) +async def create_case(payload: SellerCaseCreateRequest) -> SellerCaseResponse: + investigation = await store.get(payload.investigation_id) + if investigation is None: + raise HTTPException(status_code=404, detail="Investigation not found") + seller_case = await store.create_case(payload) + logger.info("Seller case queued: %s", seller_case.case_id) + asyncio.create_task(seller_case_orchestrator.run_case(seller_case.case_id)) + return seller_case + + +@app.get("/cases/{case_id}", response_model=SellerCaseResponse) +async def get_case(case_id: str) -> SellerCaseResponse: + seller_case = await store.get_case(case_id) + if seller_case is None: + raise HTTPException(status_code=404, detail="Seller case not found") + return seller_case + + +def run() -> None: + uvicorn.run("backend.main:app", host="127.0.0.1", port=8000, reload=True) + + +if __name__ == "__main__": + run() diff --git a/TinyDetective/docs/seller-case-feature-plan.md b/TinyDetective/docs/seller-case-feature-plan.md new file mode 100644 index 000000000..0b4418db3 --- /dev/null +++ b/TinyDetective/docs/seller-case-feature-plan.md @@ -0,0 +1,605 @@ +# Seller Case Feature Plan + +## Goal + +Add a post-investigation workflow that lets an analyst select a suspicious seller from counterfeit analysis results and build a seller-level enforcement case. + +The seller case workflow should: + +- Reuse the original counterfeit investigation context. +- Use TinyFish agents to inspect the selected seller page and the seller's other listings. +- Gather structured, reviewable evidence about suspicious behavior. +- Generate a draft case for manual review. +- Produce a marketplace-facing request for action backed by evidence. + +## Product Intent + +The counterfeit scan identifies suspicious listings. + +The seller case workflow answers a second question: + +> Is this seller operating in a way that justifies escalation to the marketplace trust and safety team? + +This feature should remain human-in-the-loop in V1. The system may draft a case and request for action, but it must not auto-submit reports to marketplace authorities. + +## Scope + +### In Scope + +- Build a seller case from a selected suspicious listing. +- Deep-dive the seller profile/storefront. +- Discover and analyze additional listings from the same seller. +- Identify repeated suspicious patterns. +- Collect structured evidence with references. +- Draft a reviewable seller case. +- Draft a marketplace-facing action request. +- Persist case state and progress. +- Display live progress and evidence in the frontend. + +### Out of Scope for V1 + +- Auto-submission to marketplaces. +- Legal conclusions beyond evidence-backed suspicion. +- Full case-management workflow with approvals and assignments. +- Cross-marketplace seller identity resolution. +- Automated image-matching infrastructure beyond hooks and placeholders. + +## User Flow + +1. User runs counterfeit analysis. +2. User reviews ranked suspicious listings. +3. User clicks `Build Seller Case` on a specific listing. +4. Backend creates a `SellerCase` linked to the originating investigation result. +5. TinyFish agents inspect the seller page and the seller's listings. +6. Evidence is collected and structured. +7. A case draft agent produces: + - seller summary + - suspicious listing summary + - evidence-backed reasoning + - recommended action + - marketplace-facing request text +8. User reviews the draft and exports or copies it for manual submission. + +## High-Level Architecture + +### Existing System Reuse + +The feature should build on the existing investigation stack: + +- TinyFish runtime and client integration +- agent task state and progress tracking +- orchestrator pattern +- storage layer +- frontend polling and live activity UI + +### New Workflow + +Add a separate seller-case pipeline rather than folding this into the base counterfeit investigation flow. + +Recommended new orchestrator: + +- `services/seller_case_orchestrator.py` + +Recommended new agents: + +- `agents/seller_profile_agent.py` +- `agents/seller_listing_discovery_agent.py` +- `agents/seller_listing_analysis_agent.py` +- `agents/seller_evidence_agent.py` +- `agents/case_draft_agent.py` + +Recommended adapters: + +- `adapters/seller_page_adapter.py` +- `adapters/seller_listing_adapter.py` + +Recommended models: + +- `models/case_schemas.py` + +## Core Workflow Steps + +### Step 1: Case Creation + +The selected suspicious listing is used to create a seller case seed. + +Inputs: + +- originating `investigation_id` +- `source_url` +- selected suspicious `ComparisonResult` +- `seller_name` +- `seller/storefront URL` if available +- `marketplace` + +Outputs: + +- new `SellerCase` +- initial progress state + +### Step 2: Seller Profile Research + +Inspect the seller page/storefront and extract: + +- seller/store name +- seller/store URL +- seller ID if present +- rating +- follower count +- response rate if present +- join date or store age +- location +- profile text +- seller badges +- official/authorized claims +- storefront screenshots + +### Step 3: Seller Listing Discovery + +Discover the seller's listings from the seller storefront. + +Extract: + +- listing URLs +- listing titles +- prices +- thumbnails +- visible categories +- seller metadata on listing cards + +This should support pagination and partial progress persistence. + +### Step 4: Seller Listing Analysis + +Analyze the seller's discovered listings and identify which ones are likely relevant to the protected brand or product family. + +Signals to inspect: + +- brand references +- title similarity +- product family overlap +- repeated pricing anomalies +- copied or near-copied descriptions +- repeated suspicious materials or colorways +- storefront claims that mimic official branding + +This step should fan out in parallel per relevant listing. + +### Step 5: Evidence Synthesis + +Convert seller-level and listing-level findings into structured evidence. + +Each evidence item should include: + +- evidence type +- source URL +- related listing URL if applicable +- extracted fact +- source value +- candidate value +- confidence +- note +- screenshot path or artifact reference when available +- timestamp + +### Step 6: Case Drafting + +The case draft agent should produce: + +- concise seller-level summary +- suspect listing summary +- evidence-backed reasoning +- recommendation for action +- marketplace-facing draft request + +The draft should use cautious language such as: + +- `suspected counterfeit activity` +- `suspected infringement` +- `requires manual review` + +## Data Model Tasks + +### Task Group: Seller Case Models + +- [ ] Add `SellerCase` +- [ ] Add `SellerProfile` +- [ ] Add `SellerListing` +- [ ] Add `SellerCaseEvidenceItem` +- [ ] Add `ActionRequestDraft` +- [ ] Add `SellerCaseListItem` + +### Suggested Model Fields + +#### SellerCase + +- `case_id` +- `investigation_id` +- `source_url` +- `selected_product_url` +- `marketplace` +- `seller_name` +- `seller_url` +- `status` +- `summary` +- `seller_profile` +- `suspect_listings` +- `evidence` +- `draft_action_request` +- `raw_agent_outputs` +- `error` +- `created_at` +- `updated_at` + +#### SellerProfile + +- `seller_name` +- `seller_url` +- `seller_id` +- `marketplace` +- `rating` +- `follower_count` +- `store_age` +- `location` +- `badges` +- `profile_text` +- `official_claims` +- `screenshot_urls` + +#### SellerListing + +- `listing_url` +- `title` +- `price` +- `currency` +- `brand` +- `category` +- `seller_name` +- `image_urls` +- `signals` +- `analysis_summary` + +#### SellerCaseEvidenceItem + +- `type` +- `field` +- `source_url` +- `listing_url` +- `source_value` +- `candidate_value` +- `confidence` +- `note` +- `artifact_refs` +- `captured_at` + +#### ActionRequestDraft + +- `title` +- `summary` +- `suspected_violation_type` +- `reasoning` +- `recommended_action` +- `marketplace_request_text` +- `evidence_refs` + +## Backend API Tasks + +### Task Group: Case Endpoints + +- [ ] Add `POST /cases` +- [ ] Add `GET /cases/:id` +- [ ] Add `GET /cases` +- [ ] Add `POST /cases/:id/redraft` +- [ ] Add export endpoint later + +### Endpoint Behavior + +#### `POST /cases` + +Creates a seller case from an investigation result. + +Request should include: + +- `investigation_id` +- `source_url` +- `product_url` +- `marketplace` +- `seller_name` +- `seller_url` if available + +Response should include: + +- `case_id` +- `status` + +#### `GET /cases/:id` + +Returns: + +- current case status +- seller profile +- suspect listings +- evidence +- draft action request +- raw task states + +#### `GET /cases` + +Returns recent seller cases for dashboard/history use. + +## Agent Tasks + +### Task Group: SellerProfileAgent + +- [ ] Create `SellerProfileAgent` +- [ ] Create seller profile extraction prompt +- [ ] Extract structured seller metadata +- [ ] Capture profile/storefront screenshots +- [ ] Normalize official claim signals + +Definition of done: + +- Agent returns a validated `SellerProfile` +- Missing fields degrade gracefully +- Output contains enough metadata for downstream reasoning + +### Task Group: SellerListingDiscoveryAgent + +- [ ] Create `SellerListingDiscoveryAgent` +- [ ] Support seller-storefront listing enumeration +- [ ] Support pagination +- [ ] Return listing URLs and lightweight metadata +- [ ] Persist progress while paging + +Definition of done: + +- Agent can enumerate seller listings from a storefront page +- Partial failures do not crash the whole case +- Result is structured and deduplicated + +### Task Group: SellerListingAnalysisAgent + +- [ ] Create `SellerListingAnalysisAgent` +- [ ] Compare seller listings against source product and brand family +- [ ] Score suspiciousness per listing +- [ ] Identify repeated suspicious patterns +- [ ] Return explainable listing-level findings + +Definition of done: + +- Agent returns structured suspiciousness output for each analyzed listing +- Results are explainable and evidence-friendly +- Parallel execution is supported + +### Task Group: SellerEvidenceAgent + +- [ ] Create `SellerEvidenceAgent` +- [ ] Convert findings into audit-friendly evidence objects +- [ ] Include artifact references when available +- [ ] Group evidence by seller-level and listing-level patterns + +Definition of done: + +- Evidence can be rendered directly in the UI +- Each evidence item includes source URL, note, and confidence +- Evidence objects can be cited by the case draft agent + +### Task Group: CaseDraftAgent + +- [ ] Create `CaseDraftAgent` +- [ ] Draft seller-level summary +- [ ] Draft platform-facing request +- [ ] Reference specific evidence items +- [ ] Use cautious enforcement language + +Definition of done: + +- Draft includes reasoning backed by evidence references +- Draft is suitable for analyst review +- Draft does not overstate unsupported conclusions + +## Orchestration Tasks + +### Task Group: SellerCaseOrchestrator + +- [ ] Create `SellerCaseOrchestrator` +- [ ] Persist case state through all stages +- [ ] Reuse existing agent task state structure +- [ ] Reuse existing progress update patterns +- [ ] Support async execution and partial progress + +### Required Pipeline + +1. create case +2. seller profile extraction +3. seller listing discovery +4. relevant listing selection +5. parallel listing analysis +6. evidence synthesis +7. case draft generation + +### Parallelism Requirements + +The following must run in parallel where possible: + +- analysis of seller listings +- listing detail extraction for relevant listings +- evidence preparation for independent listings if architecture permits + +Definition of done: + +- The seller-case workflow persists progress like investigations do +- Independent listing analysis tasks run concurrently +- Failures in one listing do not invalidate the entire case + +## Storage Tasks + +### Task Group: Persistence + +- [ ] Extend storage to persist seller cases +- [ ] Link seller cases to investigations +- [ ] Persist evidence and case drafts +- [ ] Persist raw agent task outputs +- [ ] Persist activity logs for seller-case runs + +Definition of done: + +- Cases survive server restarts +- Case history can be queried +- Raw and derived outputs remain linked + +## Frontend Tasks + +### Task Group: Results Page Integration + +- [ ] Add `Build Seller Case` button to suspicious results +- [ ] Only show action for appropriate results +- [ ] Pass selected listing and seller metadata into case creation flow + +Definition of done: + +- User can start a seller case from the investigation results page +- Action is visible only when seller context exists + +### Task Group: Seller Case Page + +- [ ] Add seller case detail route/page +- [ ] Show progress and live activity +- [ ] Show seller profile summary +- [ ] Show suspect listings table/cards +- [ ] Show evidence list +- [ ] Show draft action request +- [ ] Add reviewer notes field + +Definition of done: + +- User can inspect the full seller case in one place +- Progress updates while the case is running +- Evidence and draft are readable and actionable + +### Task Group: Export UX + +- [ ] Add copy-to-clipboard export +- [ ] Add markdown export +- [ ] Add PDF/HTML export later + +Definition of done: + +- Analyst can export a case draft for manual submission + +## Evidence Standards + +Every evidence item should be: + +- traceable to a source URL +- time-stamped +- confidence-scored +- understandable without reading raw model output +- renderable in UI and export + +Recommended evidence categories: + +- `brand_misuse` +- `repeated_suspicious_listing` +- `suspicious_price_pattern` +- `copied_description` +- `image_reuse` +- `official_store_mimicry` +- `policy_badge_mismatch` +- `repeat_product_family_pattern` + +## Safety and Policy Constraints + +- Human review is mandatory in V1. +- No automatic report submission. +- No claims stronger than the evidence supports. +- Use cautious and review-friendly language. +- Preserve raw evidence and provenance for auditability. + +## Suggested Delivery Order + +### Phase 1: MVP Seller Case Flow + +- [ ] Add case models +- [ ] Add storage and endpoints +- [ ] Add `Build Seller Case` UI action +- [ ] Add seller profile extraction +- [ ] Add seller listing discovery +- [ ] Add parallel listing analysis +- [ ] Add evidence synthesis +- [ ] Add draft case generation + +Phase 1 definition of done: + +- Analyst can create a seller case from a suspicious result +- TinyFish agents analyze seller profile and listings +- Seller listing analysis runs in parallel +- Evidence and draft are persisted and viewable + +### Phase 2: Stronger Evidence and Review + +- [ ] Add screenshots and artifact persistence +- [ ] Add richer repeated-pattern detection +- [ ] Add stronger export support +- [ ] Add reviewer notes and redraft workflow + +Phase 2 definition of done: + +- Case output is exportable +- Reviewer can edit or annotate before submission +- Evidence quality is suitable for manual enforcement escalation + +### Phase 3: Operational Workflow + +- [ ] Add case history dashboard +- [ ] Add reviewer states and assignment +- [ ] Add marketplace-specific draft templates +- [ ] Add audit trail improvements + +Phase 3 definition of done: + +- Seller cases are manageable as a repeatable analyst workflow + +## Global Definition of Done + +This feature is complete when all of the following are true: + +- User can create a seller case from a counterfeit analysis result. +- Seller page and seller listings are analyzed with TinyFish agents. +- Listing analysis executes in parallel. +- Evidence is structured, persisted, and tied to source URLs. +- Draft case reasoning references concrete evidence. +- UI shows case progress, evidence, and the final draft. +- Case output is suitable for manual submission to marketplace authorities. +- Workflow is resilient to partial failures. +- Tests cover core agents, orchestrator flow, and API creation/fetch behavior. + +## Codex Implementation Notes + +### Keep This Separate From the Base Investigation + +Do not overload the counterfeit investigation endpoint with seller-case logic. + +Preferred pattern: + +- investigation flow stays focused on counterfeit analysis +- seller-case flow is launched from a selected result + +### Preserve Explainability + +Do not optimize for opaque scoring. Every seller-level conclusion should be backed by evidence items and source references. + +### Preserve Human Review + +Do not implement automatic submission logic unless explicitly requested in a later phase. + +### Reuse Existing Patterns + +Prefer reusing: + +- TinyFish client and runtime +- `AgentTaskState` +- existing polling/progress model +- storage conventions +- current frontend activity log and progress sections + diff --git a/TinyDetective/frontend/app.js b/TinyDetective/frontend/app.js new file mode 100644 index 000000000..fdd0ac2c4 --- /dev/null +++ b/TinyDetective/frontend/app.js @@ -0,0 +1,3781 @@ +const body = document.body; +const promptComposer = document.getElementById("prompt-composer"); +const form = document.getElementById("investigation-form"); +const sourceUrlsInput = document.getElementById("source-urls"); +const comparisonSitesInput = document.getElementById("comparison-sites"); +const resultsNode = document.getElementById("results"); +const pastRunsNode = document.getElementById("past-runs"); +const pastCasesNode = document.getElementById("past-cases"); +const historyDropdown = document.getElementById("history-dropdown"); +const historyButton = document.getElementById("history-button"); +const caseHistoryDropdown = document.getElementById("case-history-dropdown"); +const caseHistoryButton = document.getElementById("case-history-button"); +const statusPill = document.getElementById("status-pill"); +const progressText = document.getElementById("progress-text"); +const progressOverview = document.getElementById("progress-overview"); +const progressTrack = document.getElementById("progress-track"); +const progressFill = document.getElementById("progress-fill"); +const configNote = document.getElementById("config-note"); +const reportTemplate = document.getElementById("report-template"); +const matchTemplate = document.getElementById("match-template"); +const runButton = document.getElementById("run-button"); +const timelineSourceUrl = document.getElementById("timeline-source-url"); +const timelineSourceLink = document.getElementById("timeline-source-link"); +const timelineSourceFrame = document.getElementById("timeline-source-frame"); +const timelineSourceMeta = document.getElementById("timeline-source-meta"); +const timelineSearchLog = document.getElementById("timeline-search-log"); +const timelineCandidateStream = document.getElementById("timeline-candidate-stream"); +const timelineSignalGraph = document.getElementById("timeline-signal-graph"); +const timelineAnalysisLog = document.getElementById("timeline-analysis-log"); +const timelineRankingList = document.getElementById("timeline-ranking-list"); +const generateReportButton = document.getElementById("generate-report-button"); +const reportPdfFrame = document.getElementById("report-pdf-frame"); +const reportMeta = document.getElementById("report-meta"); +const reportNote = document.getElementById("report-note"); +const reportBackButton = document.getElementById("report-back-button"); +const reportOpenButton = document.getElementById("report-open-button"); +const newInvestigationButton = document.getElementById("new-investigation-button"); +const caseTitle = document.getElementById("case-title"); +const caseSubtitle = document.getElementById("case-subtitle"); +const caseStatusPill = document.getElementById("case-status-pill"); +const caseProgressText = document.getElementById("case-progress-text"); +const caseProgressTrack = document.getElementById("case-progress-track"); +const caseProgressFill = document.getElementById("case-progress-fill"); +const caseProfileSummary = document.getElementById("case-profile-summary"); +const caseSeedSummary = document.getElementById("case-seed-summary"); +const caseSuspectListings = document.getElementById("case-suspect-listings"); +const caseEvidenceGrid = document.getElementById("case-evidence-grid"); +const caseDraft = document.getElementById("case-draft"); +const caseActivityLog = document.getElementById("case-activity-log"); +const caseAgentLog = document.getElementById("case-agent-log"); +const caseBackButton = document.getElementById("case-back-button"); +const caseGenerateReportButton = document.getElementById("case-generate-report-button"); +const timelineTrack = document.getElementById("progress-list"); +const timelineNotes = { + source: document.getElementById("timeline-source-note"), + search: document.getElementById("timeline-search-note"), + candidates: document.getElementById("timeline-candidates-note"), + analysis: document.getElementById("timeline-analysis-note"), + ranking: document.getElementById("timeline-ranking-note"), +}; + +let pollTimer = null; +let currentInvestigationId = null; +let pastRunsCache = []; +let pastCasesCache = []; +let currentPhase = body.dataset.phase || "prompt"; +let lastSubmittedSourceUrl = ""; +let activeTimelineStage = "source"; +let latestInvestigationPayload = null; +let appConfig = null; +let currentReportPdfUrl = null; +let reportGenerationInFlight = false; +let caseReportGenerationInFlight = false; +let casePollTimer = null; +let currentCaseId = null; +let latestCasePayload = null; +let previousPhaseBeforeCase = "progress"; +let previousPhaseBeforeReport = "progress"; + +const defaultRunButtonLabel = runButton.textContent; +const defaultGenerateReportButtonLabel = generateReportButton?.textContent || "Generate report"; +const defaultCaseGenerateReportButtonLabel = + caseGenerateReportButton?.textContent || "Generate report"; +const persistedInvestigationStorageKey = "tinydetective:last-investigation-id"; +const persistedCaseStorageKey = "tinydetective:last-case-id"; +const progressStepDefinitions = [ + { key: "source_extraction", label: "Extract official product details" }, + { key: "candidate_discovery", label: "Search configured marketplaces" }, + { key: "candidate_triage", label: "Triage candidate pool with OpenAI" }, + { key: "product_comparison", label: "Compare candidate listings" }, + { key: "evidence", label: "Assemble supporting evidence" }, + { key: "reasoning_enrichment", label: "Refine reasoning with OpenAI" }, + { key: "ranking", label: "Rank suspicious matches" }, + { key: "research_summary", label: "Summarize the investigation" }, +]; +const progressStepIndex = Object.fromEntries( + progressStepDefinitions.map((step, index) => [step.key, index]) +); +const timelineStageDefinitions = [ + { key: "source", label: "Source Page" }, + { key: "search", label: "Live Search Behavior" }, + { key: "candidates", label: "Candidate Intake" }, + { key: "analysis", label: "Reasoning Graph" }, + { key: "ranking", label: "Ranking Ladder" }, +]; +const timelineStageItems = Object.fromEntries( + timelineStageDefinitions.map((stage) => [ + stage.key, + document.querySelector(`[data-timeline-step="${stage.key}"]`), + ]) +); +const timelineRailItems = Object.fromEntries( + timelineStageDefinitions.map((stage) => [ + stage.key, + document.querySelector(`[data-timeline-rail="${stage.key}"]`), + ]) +); +const statusLabels = { + idle: "Idle", + queued: "Queued", + running: "Running", + delayed: "Delayed", + completed: "Completed", + failed: "Failed", + reviewed: "Reviewed", + exported: "Exported", +}; +const progressStateLabels = { + pending: "Pending", + queued: "Queued", + running: "In Progress", + delayed: "Delayed", + completed: "Done", + failed: "Failed", +}; +const timelineStateLabels = { + pending: "Pending", + queued: "Queued", + running: "Live", + delayed: "Delayed", + completed: "Ready", + failed: "Failed", +}; + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (character) => { + const entities = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[character] || character; + }); +} + +function formatHostname(value) { + if (!value) { + return "Unknown source"; + } + + try { + return new URL(value).hostname.replace(/^www\./, ""); + } catch { + return String(value); + } +} + +function formatCompactCurrency(value, currency) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return "Price unavailable"; + } + + try { + return new Intl.NumberFormat([], { + style: "currency", + currency: currency || "USD", + maximumFractionDigits: 0, + }).format(Number(value)); + } catch { + return `${currency || ""} ${value}`.trim(); + } +} + +function formatElapsedSeconds(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return null; + } + + const seconds = Math.max(1, Math.round(Number(value))); + if (seconds < 60) { + return `${seconds}s`; + } + if (seconds < 3600) { + return `${Math.round(seconds / 60)}m`; + } + return `${Math.round(seconds / 3600)}h`; +} + +function normalizeScore(value) { + const numericValue = Number(value) || 0; + if (numericValue > 1) { + return Math.max(0, Math.min(numericValue / 10, 1)); + } + return Math.max(0, Math.min(numericValue, 1)); +} + +function getRiskColor(value) { + const normalizedValue = normalizeScore(value); + if (normalizedValue >= 0.75) { + return "hsl(7 72% 46%)"; + } + if (normalizedValue >= 0.45) { + return "hsl(35 82% 46%)"; + } + return "hsl(145 58% 38%)"; +} + +function humanizeFieldName(value, { capitalize = true } = {}) { + const normalized = String(value || "field") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (!normalized) { + return "field"; + } + + if (!capitalize) { + return normalized.toLowerCase(); + } + + return normalized.replace(/\b\w/g, (character) => character.toUpperCase()); +} + +function formatReasonText(value) { + return String(value || "") + .replace(/\b[a-z0-9]+(?:[_-]+[a-z0-9]+)+\b/gi, (token) => + humanizeFieldName(token, { capitalize: false }) + ) + .replace(/\s+/g, " ") + .trim(); +} + +function humanizeSignal(signal) { + const signalMap = { + suspiciously_low_price: "The listing is priced materially below the official source price.", + brand_mismatch: "The brand information does not align with the official product.", + copied_description_with_discount_pricing: + "The listing appears to reuse official product copy while also discounting heavily.", + }; + + return signalMap[signal] || humanizeFieldName(signal); +} + +function formatNaturalList(items) { + const values = [...new Set((items || []).filter(Boolean))]; + if (values.length === 0) { + return ""; + } + if (values.length === 1) { + return values[0]; + } + if (values.length === 2) { + return `${values[0]} and ${values[1]}`; + } + return `${values.slice(0, -1).join(", ")}, and ${values[values.length - 1]}`; +} + +function getRiskReasonLines(match) { + const lines = []; + const evidence = match.evidence || []; + const priceEvidence = evidence.find((item) => item.field === "price"); + const descriptionEvidence = evidence.find((item) => item.field === "description"); + + (match.suspicious_signals || []).forEach((signal) => { + lines.push(humanizeSignal(signal)); + }); + + if (priceEvidence && !lines.some((line) => line.toLowerCase().includes("priced materially below"))) { + lines.push(priceEvidence.note); + } + + if ( + descriptionEvidence && + !lines.some((line) => line.toLowerCase().includes("reuse official product copy")) + ) { + lines.push(descriptionEvidence.note); + } + + if (lines.length === 0) { + if (normalizeScore(match.counterfeit_risk_score) < 0.35) { + lines.push("Few direct counterfeit indicators were detected in the captured evidence."); + } else { + lines.push( + formatReasonText(match.reason || "The backend did not return a more specific counterfeit-risk rationale.") + ); + } + } + + return [...new Set(lines)]; +} + +function getMatchReasonLines(match) { + const evidence = match.evidence || []; + const matchedFields = evidence + .filter((item) => /matches between source and candidate/i.test(item.note)) + .map((item) => humanizeFieldName(item.field)); + const mismatchedFields = evidence + .filter((item) => /does not match between source and candidate/i.test(item.note)) + .map((item) => humanizeFieldName(item.field)); + const lines = []; + + if (matchedFields.length > 0) { + lines.push(`Aligned fields: ${formatNaturalList(matchedFields)}.`); + } + + if (mismatchedFields.length > 0) { + lines.push(`Mismatched fields: ${formatNaturalList(mismatchedFields)}.`); + } + + if (normalizeScore(match.match_score) < 0.5) { + if (matchedFields.length <= 1) { + lines.push("Too few structured attributes aligned strongly with the official product."); + } + if (mismatchedFields.length === 0 && matchedFields.length === 0) { + lines.push("The backend found only weak directional similarity rather than a strong structured match."); + } + } else if (normalizeScore(match.match_score) >= 0.75 && matchedFields.length > 0) { + lines.push("Multiple structured attributes line up with the official product, which keeps the match score elevated."); + } + + if (lines.length === 0) { + lines.push(formatReasonText(match.reason || "The backend did not return a more specific match rationale.")); + } + + return [...new Set(lines)]; +} + +function sanitizePlainText(value) { + return String(value ?? "") + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201c\u201d]/g, '"') + .replace(/[\u2013\u2014]/g, "-") + .replace(/\u2026/g, "...") + .replace(/\u00a0/g, " ") + .trim(); +} + +function formatReportDate(value) { + if (!value) { + return "Unavailable"; + } + + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return "Unavailable"; + } + + return timestamp.toLocaleString(); +} + +function getBrandWebsite(payload) { + return ( + appConfig?.brand_landing_page_url || + payload?.reports?.[0]?.source_url || + lastSubmittedSourceUrl || + "Unavailable" + ); +} + +function getSuggestedActionsForReport(report) { + const rankedListings = getRankingSnapshots(report); + const highRiskListings = rankedListings.filter((item) => normalizeScore(item.counterfeit_risk_score) >= 0.75); + const mediumRiskListings = rankedListings.filter((item) => { + const score = normalizeScore(item.counterfeit_risk_score); + return score >= 0.45 && score < 0.75; + }); + const officialLikeListings = collectCompletedComparisons(report).filter( + (item) => item.official_store_signals && item.official_store_signals.length > 0 + ); + + if (highRiskListings.length > 0) { + return [ + `Preserve evidence for the ${highRiskListings.length} highest-risk listing${highRiskListings.length === 1 ? "" : "s"} and capture screenshots before they change.`, + "Escalate the top suspicious URLs to marketplace trust and safety or brand protection workflows for takedown review.", + "Cross-check seller identity, price, and product attributes against authorized channels before enforcement.", + ]; + } + + if (mediumRiskListings.length > 0) { + return [ + "Queue the medium-risk listings for manual analyst review before any takedown request is sent.", + "Compare seller metadata, pricing, and evidence notes against the official product page to confirm whether escalation is warranted.", + "Continue monitoring search coverage in case stronger lookalikes appear in subsequent crawls.", + ]; + } + + if (officialLikeListings.length > 0) { + return [ + "Do not treat official-store-like listings as counterfeit without manual confirmation.", + "Review the official-store signals first and separate those listings from enforcement queues.", + ]; + } + + return [ + "No high-confidence counterfeit target was found in the ranked set; keep monitoring and rerun the investigation if new listings appear.", + "Archive the collected evidence and search coverage as a baseline for future comparisons.", + ]; +} + +function buildOperationalTrace(task) { + const output = task.output_payload || {}; + const traceDetails = []; + + if (task.agent_name === "candidate_discovery") { + traceDetails.push( + `site: ${sanitizePlainText( + output.comparison_site || task.input_payload?.comparison_site || "Unavailable" + )}` + ); + traceDetails.push( + `query: ${sanitizePlainText(output.search_query || task.input_payload?.search_query || "Unavailable")}` + ); + if (output.candidate_count !== undefined) { + traceDetails.push(`candidate count: ${sanitizePlainText(output.candidate_count)}`); + } + } + + if (task.agent_name === "product_comparison" && output.comparison?.product_url) { + traceDetails.push(`product: ${sanitizePlainText(output.comparison.product_url)}`); + } + + if (task.agent_name === "ranking" && output.ranked_product_urls?.length) { + traceDetails.push(`ranked URLs: ${sanitizePlainText(output.ranked_product_urls.length)}`); + } + + if (task.error) { + traceDetails.push(`error: ${sanitizePlainText(task.error)}`); + } + + const providerState = describeProviderState(task); + if (providerState) { + traceDetails.push(`provider: ${sanitizePlainText(providerState)}`); + } + + return traceDetails; +} + +function getReportLimitations(report) { + const gaps = [ + "This dossier captures observed URLs, extracted listing data, and heuristic comparison evidence only. It does not itself prove infringement or counterfeit authenticity as a legal conclusion.", + "Trademark registrations, chain-of-title documents, prior enforcement history, and counsel-reviewed legal claims should be attached separately before filing a complaint or lawsuit.", + "Screenshots, page captures, and test-buy evidence were not automatically preserved in this run and should be captured separately if platform reporting or litigation support requires them.", + ]; + + const missingSellerCount = getRankingSnapshots(report) + .slice(0, 5) + .filter((item) => !item.candidate_product?.seller_name).length; + + if (missingSellerCount > 0) { + gaps.push( + `${missingSellerCount} ranked listing${missingSellerCount === 1 ? "" : "s"} did not include seller identity in the captured data and may need manual follow-up.` + ); + } + + return gaps; +} + +function buildInvestigationPdf(payload) { + const jsPdfApi = window.jspdf?.jsPDF; + if (!jsPdfApi) { + throw new Error("The PDF renderer is not available in this browser session."); + } + + const doc = new jsPdfApi({ + orientation: "portrait", + unit: "pt", + format: "letter", + compress: true, + }); + + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 48; + const contentWidth = pageWidth - margin * 2; + let cursorY = margin; + + const ensureSpace = (height = 18) => { + if (cursorY + height <= pageHeight - margin) { + return; + } + doc.addPage(); + cursorY = margin; + }; + + const drawRule = () => { + ensureSpace(16); + doc.setDrawColor(204, 198, 188); + doc.setLineWidth(0.7); + doc.line(margin, cursorY, pageWidth - margin, cursorY); + cursorY += 16; + }; + + const addKicker = (text) => { + ensureSpace(12); + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); + doc.setTextColor(132, 124, 113); + doc.text(sanitizePlainText(String(text || "").toUpperCase()), margin, cursorY); + cursorY += 14; + }; + + const addHeading = (text, size = 18) => { + const lines = doc.splitTextToSize(sanitizePlainText(text), contentWidth); + ensureSpace(lines.length * (size + 4)); + doc.setFont("helvetica", "bold"); + doc.setFontSize(size); + doc.setTextColor(54, 46, 39); + doc.text(lines, margin, cursorY); + cursorY += lines.length * (size + 4); + }; + + const addParagraph = (text, options = {}) => { + const fontSize = options.fontSize || 11; + const lineHeight = options.lineHeight || 16; + const lines = doc.splitTextToSize(sanitizePlainText(text), contentWidth); + ensureSpace(lines.length * lineHeight + 6); + doc.setFont("helvetica", options.bold ? "bold" : "normal"); + doc.setFontSize(fontSize); + doc.setTextColor(options.muted ? 110 : 70, options.muted ? 103 : 62, options.muted ? 95 : 54); + doc.text(lines, margin, cursorY); + cursorY += lines.length * lineHeight + 6; + }; + + const addBulletList = (items, options = {}) => { + const values = (items || []).map((item) => sanitizePlainText(item)).filter(Boolean); + if (values.length === 0) { + return; + } + + const fontSize = options.fontSize || 11; + const lineHeight = options.lineHeight || 15; + doc.setFont("helvetica", "normal"); + doc.setFontSize(fontSize); + doc.setTextColor(70, 62, 54); + + values.forEach((item) => { + const bulletX = margin + 4; + const textX = margin + 14; + const lines = doc.splitTextToSize(item, contentWidth - 18); + ensureSpace(lines.length * lineHeight + 4); + doc.text("-", bulletX, cursorY); + doc.text(lines, textX, cursorY); + cursorY += lines.length * lineHeight + 4; + }); + + cursorY += 2; + }; + + const addDefinitionList = (rows) => { + const filteredRows = (rows || []).filter(([, value]) => value !== null && value !== undefined && value !== ""); + if (filteredRows.length === 0) { + return; + } + + filteredRows.forEach(([label, value]) => { + const line = `${sanitizePlainText(label)}: ${sanitizePlainText(value)}`; + addParagraph(line, { fontSize: 10.5, lineHeight: 15 }); + }); + }; + + const addSection = (kicker, heading, body) => { + if (cursorY > margin + 8) { + drawRule(); + } + addKicker(kicker); + addHeading(heading, 16); + body(); + }; + + const reports = payload?.reports || []; + const brandWebsite = getBrandWebsite(payload); + + addKicker("TinyDetective"); + addHeading("Counterfeit Research Evidence Dossier", 22); + addParagraph( + "Prepared from the captured TinyDetective investigation outputs for internal review, marketplace complaint preparation, and counsel handoff. This report is an evidence summary, not legal advice.", + { fontSize: 11.5, lineHeight: 17 } + ); + addDefinitionList([ + ["Investigation ID", payload?.investigation_id || "Unavailable"], + ["Status", payload?.status || "Unavailable"], + ["Created", formatReportDate(payload?.created_at)], + ["Updated", formatReportDate(payload?.updated_at)], + ["Brand website", brandWebsite], + ]); + + reports.forEach((report, index) => { + const sourceProduct = report.extracted_source_product || {}; + const candidateTasks = getCandidateTasks(report); + const discoveredCandidates = collectDiscoveredCandidates(report); + const completedComparisons = collectCompletedComparisons(report); + const rankedListings = getRankingSnapshots(report); + const suggestedActions = getSuggestedActionsForReport(report); + const suspiciousUrls = rankedListings.map((item) => String(item.product_url)); + const operationalTrace = (report.raw_agent_outputs || []).map((task) => { + const details = [ + task.agent_name || "agent", + task.status || "unknown", + ...buildOperationalTrace(task), + ]; + return details.join(" | "); + }); + + addSection(`Source ${index + 1}`, "Investigation Scope", () => { + addDefinitionList([ + ["Input URL", report.source_url || lastSubmittedSourceUrl], + ["Brand website", brandWebsite], + ["Report summary", report.summary || "No summary returned."], + ["Report error", report.error || ""], + ["Official-store exclusions", report.excluded_official_store_count ?? 0], + ]); + }); + + addSection(`Source ${index + 1}`, "Official Product Reference", () => { + addDefinitionList([ + ["Brand", sourceProduct.brand || "Unavailable"], + ["Product name", sourceProduct.product_name || "Unavailable"], + ["Category", sourceProduct.category || "Unavailable"], + ["Subcategory", sourceProduct.subcategory || "Unavailable"], + ["SKU", sourceProduct.sku || "Unavailable"], + ["Model", sourceProduct.model || "Unavailable"], + ["Price", sourceProduct.price !== null && sourceProduct.price !== undefined + ? formatCompactCurrency(sourceProduct.price, sourceProduct.currency) + : "Unavailable"], + ["Color", sourceProduct.color || "Unavailable"], + ["Size", sourceProduct.size || "Unavailable"], + ["Material", sourceProduct.material || "Unavailable"], + ["Features", (sourceProduct.features || []).join(", ") || "Unavailable"], + ]); + }); + + addSection(`Source ${index + 1}`, "Ranked Listings of Concern", () => { + if (rankedListings.length === 0) { + addParagraph("No ranked suspicious or lookalike listings were available in this run.", { + muted: true, + }); + return; + } + + rankedListings.slice(0, 5).forEach((match, rankIndex) => { + addParagraph( + `#${rankIndex + 1} ${match.candidate_product?.title || match.candidate_product?.model || match.product_url}`, + { bold: true, fontSize: 12, lineHeight: 17 } + ); + addDefinitionList([ + ["Listing URL", match.product_url], + ["Marketplace", match.marketplace || formatHostname(match.product_url)], + ["Seller", match.candidate_product?.seller_name || "Unavailable"], + ["Risk score", Number(match.counterfeit_risk_score || 0).toFixed(2)], + ["Match score", Number(match.match_score || 0).toFixed(2)], + ]); + addParagraph(`Observed rationale: ${match.reason || "No reason returned."}`, { + fontSize: 10.5, + lineHeight: 15, + }); + addBulletList( + getRiskReasonLines(match).map((line) => `Risk reasoning: ${line}`) + ); + addBulletList( + getMatchReasonLines(match).map((line) => `Match reasoning: ${line}`) + ); + addBulletList( + (match.evidence || []).slice(0, 5).map((item) => { + const sourceValue = + item.source_value !== null && item.source_value !== undefined ? ` | source: ${item.source_value}` : ""; + const candidateValue = + item.candidate_value !== null && item.candidate_value !== undefined + ? ` | candidate: ${item.candidate_value}` + : ""; + return `Evidence - ${humanizeFieldName(item.field)}: ${item.note}${sourceValue}${candidateValue}`; + }), + { fontSize: 10, lineHeight: 14 } + ); + cursorY += 4; + }); + }); + + addSection(`Source ${index + 1}`, "Suspicious URLs", () => { + addBulletList( + suspiciousUrls.length > 0 ? suspiciousUrls : ["No suspicious URLs were ranked in this run."] + ); + }); + + addSection(`Source ${index + 1}`, "Marketplace Search Coverage", () => { + if (candidateTasks.length === 0) { + addParagraph("No marketplace search tasks were recorded.", { muted: true }); + return; + } + + addBulletList( + candidateTasks.map((task) => { + const query = task.output_payload?.search_query || task.input_payload?.search_query || "Unavailable"; + const site = task.output_payload?.comparison_site || task.input_payload?.comparison_site || "Unavailable"; + const candidateCount = task.output_payload?.candidate_count; + return `${formatHostname(site)} | query: ${query} | status: ${task.status || "unknown"}${ + candidateCount !== undefined ? ` | candidates: ${candidateCount}` : "" + }`; + }) + ); + }); + + addSection(`Source ${index + 1}`, "Discovered Listing Inventory", () => { + if (discoveredCandidates.length === 0) { + addParagraph("No candidate listings were captured.", { muted: true }); + return; + } + + addBulletList( + discoveredCandidates.map((candidate) => { + const title = candidate.title || candidate.model || candidate.product_url; + const price = + candidate.price !== null && candidate.price !== undefined + ? formatCompactCurrency(candidate.price, candidate.currency) + : "Price unavailable"; + return `${title} | ${candidate.product_url} | marketplace: ${ + candidate.marketplace || formatHostname(candidate.product_url) + } | seller: ${candidate.seller_name || "Unavailable"} | price: ${price} | query: ${ + candidate.discovery_query || "Unavailable" + }`; + }) + ); + }); + + addSection(`Source ${index + 1}`, "Comparison Evidence Inventory", () => { + if (completedComparisons.length === 0) { + addParagraph("No completed comparison records were available.", { muted: true }); + return; + } + + addBulletList( + completedComparisons.map((comparison) => { + const signals = (comparison.suspicious_signals || []).join(", ") || "None"; + return `${ + comparison.candidate_product?.title || comparison.candidate_product?.model || comparison.product_url + } | ${comparison.product_url} | risk ${Number(comparison.counterfeit_risk_score || 0).toFixed(2)} | match ${Number( + comparison.match_score || 0 + ).toFixed(2)} | signals: ${signals}`; + }) + ); + }); + + addSection(`Source ${index + 1}`, "Recommended Next Actions", () => { + addBulletList(suggestedActions); + }); + + addSection(`Source ${index + 1}`, "Complaint-Prep Checklist", () => { + addBulletList([ + "Preserve the direct listing URL for each suspicious entry and record the capture date and time on any screenshot or exported artifact.", + "Attach trademark ownership, authorization, or registration materials separately before filing any formal complaint or legal action.", + "Confirm seller identity, marketplace storefront details, and product identifiers before requesting takedown or asserting infringement.", + "Separate factual observations from legal conclusions; use this dossier as supporting evidence for counsel or trust-and-safety review.", + "If a direct link becomes unavailable, capture a screenshot of the listing or ad together with the visible seller and product details.", + ]); + }); + + addSection(`Source ${index + 1}`, "Limitations and Gaps", () => { + addBulletList(getReportLimitations(report)); + }); + + addSection(`Source ${index + 1}`, "Operational Trace", () => { + addBulletList( + operationalTrace.length > 0 ? operationalTrace : ["No operational trace was captured."] + ); + }); + }); + + return doc.output("blob"); +} + +function buildSellerCasePdf(payload) { + const jsPdfApi = window.jspdf?.jsPDF; + if (!jsPdfApi) { + throw new Error("The PDF renderer is not available in this browser session."); + } + + const doc = new jsPdfApi({ + orientation: "portrait", + unit: "pt", + format: "letter", + compress: true, + }); + + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 48; + const contentWidth = pageWidth - margin * 2; + let cursorY = margin; + + const ensureSpace = (height = 18) => { + if (cursorY + height <= pageHeight - margin) { + return; + } + doc.addPage(); + cursorY = margin; + }; + + const drawRule = () => { + ensureSpace(16); + doc.setDrawColor(204, 198, 188); + doc.setLineWidth(0.7); + doc.line(margin, cursorY, pageWidth - margin, cursorY); + cursorY += 16; + }; + + const addKicker = (text) => { + ensureSpace(12); + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); + doc.setTextColor(132, 124, 113); + doc.text(sanitizePlainText(String(text || "").toUpperCase()), margin, cursorY); + cursorY += 14; + }; + + const addHeading = (text, size = 18) => { + const lines = doc.splitTextToSize(sanitizePlainText(text), contentWidth); + ensureSpace(lines.length * (size + 4)); + doc.setFont("helvetica", "bold"); + doc.setFontSize(size); + doc.setTextColor(54, 46, 39); + doc.text(lines, margin, cursorY); + cursorY += lines.length * (size + 4); + }; + + const addParagraph = (text, options = {}) => { + const fontSize = options.fontSize || 11; + const lineHeight = options.lineHeight || 16; + const lines = doc.splitTextToSize(sanitizePlainText(text), contentWidth); + ensureSpace(lines.length * lineHeight + 6); + doc.setFont("helvetica", options.bold ? "bold" : "normal"); + doc.setFontSize(fontSize); + doc.setTextColor(options.muted ? 110 : 70, options.muted ? 103 : 62, options.muted ? 95 : 54); + doc.text(lines, margin, cursorY); + cursorY += lines.length * lineHeight + 6; + }; + + const addBulletList = (items, options = {}) => { + const values = (items || []).map((item) => sanitizePlainText(item)).filter(Boolean); + if (values.length === 0) { + return; + } + const fontSize = options.fontSize || 11; + const lineHeight = options.lineHeight || 15; + doc.setFont("helvetica", "normal"); + doc.setFontSize(fontSize); + doc.setTextColor(70, 62, 54); + values.forEach((item) => { + const bulletX = margin + 4; + const textX = margin + 14; + const lines = doc.splitTextToSize(item, contentWidth - 18); + ensureSpace(lines.length * lineHeight + 4); + doc.text("-", bulletX, cursorY); + doc.text(lines, textX, cursorY); + cursorY += lines.length * lineHeight + 4; + }); + cursorY += 2; + }; + + const addDefinitionList = (rows) => { + const filteredRows = (rows || []).filter(([, value]) => value !== null && value !== undefined && value !== ""); + filteredRows.forEach(([label, value]) => { + addParagraph(`${sanitizePlainText(label)}: ${sanitizePlainText(value)}`, { + fontSize: 10.5, + lineHeight: 15, + }); + }); + }; + + const addSection = (kicker, heading, body) => { + if (cursorY > margin + 8) { + drawRule(); + } + addKicker(kicker); + addHeading(heading, 16); + body(); + }; + + const profile = payload?.seller_profile || {}; + const selectedListing = payload?.selected_listing || {}; + const officialMatches = payload?.official_product_matches || []; + const suspectListings = payload?.suspect_listings || []; + const evidence = payload?.evidence || []; + const draft = payload?.action_request_draft || {}; + + addKicker("TinyDetective"); + addHeading("Seller Enforcement Case Dossier", 22); + addParagraph( + "Prepared from the captured TinyDetective seller-case workflow for marketplace trust-and-safety review and internal escalation. This report is an evidence summary, not legal advice.", + { fontSize: 11.5, lineHeight: 17 } + ); + addDefinitionList([ + ["Seller case ID", payload?.case_id || "Unavailable"], + ["Origin investigation", payload?.investigation_id || "Unavailable"], + ["Status", payload?.status || "Unavailable"], + ["Created", formatReportDate(payload?.created_at)], + ["Updated", formatReportDate(payload?.updated_at)], + ["Marketplace", payload?.marketplace || "Unavailable"], + ]); + + addSection("Seller Case", "Seller Profile", () => { + addDefinitionList([ + ["Seller", profile.seller_name || payload?.seller_name || "Unavailable"], + ["Storefront URL", profile.seller_url || payload?.seller_store_url || "Unavailable"], + ["Seller ID", profile.seller_id || "Unavailable"], + ["Rating", profile.rating ?? "Unavailable"], + ["Ratings count", profile.rating_count ?? "Unavailable"], + ["Followers", profile.follower_count ?? "Unavailable"], + ["Location", profile.location || "Unavailable"], + ["Official-store claims", (profile.official_store_claims || []).join(", ") || "None observed"], + ["Entry URLs analyzed", (profile.entry_urls || []).length || 0], + ["Storefront shards analyzed", (profile.storefront_shard_urls || []).length || 0], + ]); + }); + + addSection("Seller Case", "Seed Listing", () => { + addDefinitionList([ + ["Listing URL", selectedListing.product_url || payload?.product_url || "Unavailable"], + ["Title", selectedListing.candidate_product?.title || "Unavailable"], + ["Seller", selectedListing.candidate_product?.seller_name || payload?.seller_name || "Unavailable"], + ["Risk score", Number(selectedListing.counterfeit_risk_score || 0).toFixed(2)], + ["Match score", Number(selectedListing.match_score || 0).toFixed(2)], + ["Reason", selectedListing.reason || "No reason returned."], + ]); + }); + + addSection("Seller Case", "Official Product Matches", () => { + if (!officialMatches.length) { + addParagraph("No official product matches were stored for this case.", { muted: true }); + return; + } + + officialMatches.forEach((match, index) => { + addParagraph(`#${index + 1} ${match.product_url}`, { bold: true, fontSize: 12, lineHeight: 17 }); + addDefinitionList([ + ["Marketplace listing", match.product_url], + ["Official product URL", match.official_product_url || "Unavailable"], + ["Match confidence", Number(match.match_confidence || 0).toFixed(2)], + ["Rationale", match.rationale || "No rationale returned."], + ]); + }); + }); + + addSection("Seller Case", "Ranked Seller Listings of Concern", () => { + if (!suspectListings.length) { + addParagraph("No suspect seller listings were stored in this case.", { muted: true }); + return; + } + + suspectListings.forEach((listing, index) => { + addParagraph( + `#${index + 1} ${listing.candidate_product?.title || listing.product_url}`, + { bold: true, fontSize: 12, lineHeight: 17 } + ); + addDefinitionList([ + ["Listing URL", listing.product_url], + ["Seller", listing.candidate_product?.seller_name || "Unavailable"], + ["Risk score", Number(listing.counterfeit_risk_score || 0).toFixed(2)], + ["Match score", Number(listing.match_score || 0).toFixed(2)], + ["Official comparison basis", listing.comparison_basis_source_url || "Unavailable"], + ["Official match confidence", Number(listing.comparison_basis_confidence || 0).toFixed(2)], + ]); + addBulletList( + (listing.suspicious_signals || []).map((signal) => `Signal: ${humanizeFieldName(signal)}`), + { fontSize: 10, lineHeight: 14 } + ); + addBulletList( + (listing.evidence || []).slice(0, 5).map((item) => { + const sourceValue = + item.source_value !== null && item.source_value !== undefined ? ` | source: ${item.source_value}` : ""; + const candidateValue = + item.candidate_value !== null && item.candidate_value !== undefined + ? ` | candidate: ${item.candidate_value}` + : ""; + return `Evidence - ${humanizeFieldName(item.field)}: ${item.note}${sourceValue}${candidateValue}`; + }), + { fontSize: 10, lineHeight: 14 } + ); + }); + }); + + addSection("Seller Case", "Case Evidence", () => { + addBulletList( + evidence.length + ? evidence.map( + (item) => + `${item.title}: ${item.note}${item.reference_url ? ` | reference: ${item.reference_url}` : ""}` + ) + : ["No seller-case evidence objects were stored."] + ); + }); + + addSection("Seller Case", "Draft Marketplace Request", () => { + addDefinitionList([ + ["Case title", draft.case_title || "Unavailable"], + ["Recommended action", draft.recommended_action || "Unavailable"], + ["Suspected violation", draft.suspected_violation_type || "Unavailable"], + ["Confidence", Number(draft.confidence || 0).toFixed(2)], + ]); + addParagraph(draft.summary || "No case summary returned."); + addParagraph(draft.reasoning || "No case reasoning returned.", { fontSize: 10.5, lineHeight: 15 }); + addParagraph(draft.request_text || "No request text returned.", { fontSize: 10.5, lineHeight: 15 }); + }); + + addSection("Seller Case", "Operational Trace", () => { + addBulletList( + (payload?.raw_agent_outputs || []).length + ? (payload.raw_agent_outputs || []).map((task) => { + const details = [task.agent_name || "agent", task.status || "unknown", ...buildOperationalTrace(task)]; + return details.join(" | "); + }) + : ["No operational trace was captured."] + ); + }); + + return doc.output("blob"); +} + +function revokeCurrentReportPdfUrl() { + if (!currentReportPdfUrl) { + return; + } + window.URL.revokeObjectURL(currentReportPdfUrl); + currentReportPdfUrl = null; +} + +function resetReportScene() { + revokeCurrentReportPdfUrl(); + if (reportPdfFrame) { + reportPdfFrame.removeAttribute("src"); + } + if (reportOpenButton) { + reportOpenButton.hidden = true; + } + if (reportMeta) { + setTextContent(reportMeta, "The embedded PDF will be prepared from the captured investigation data."); + } + if (reportNote) { + setTextContent(reportNote, "Generate a report from step 5 to review it here."); + } +} + +function presentPdfReport(blob, payload) { + const objectUrl = window.URL.createObjectURL(blob); + revokeCurrentReportPdfUrl(); + currentReportPdfUrl = objectUrl; + + if (reportPdfFrame) { + reportPdfFrame.src = objectUrl; + } + if (reportOpenButton) { + reportOpenButton.hidden = false; + } + if (reportNote) { + setTextContent(reportNote, "The evidence dossier is ready for review."); + } + if (reportMeta) { + setTextContent( + reportMeta, + `Investigation ${payload?.investigation_id || "Unavailable"} | Updated ${formatReportDate( + payload?.updated_at + )} | Brand website ${getBrandWebsite(payload)}` + ); + } + setPhase("report"); +} + +function presentSellerCasePdfReport(blob, payload) { + const objectUrl = window.URL.createObjectURL(blob); + revokeCurrentReportPdfUrl(); + currentReportPdfUrl = objectUrl; + + if (reportPdfFrame) { + reportPdfFrame.src = objectUrl; + } + if (reportOpenButton) { + reportOpenButton.hidden = false; + } + if (reportNote) { + setTextContent(reportNote, "The seller enforcement dossier is ready for review."); + } + if (reportMeta) { + setTextContent( + reportMeta, + `Seller case ${payload?.case_id || "Unavailable"} | Updated ${formatReportDate( + payload?.updated_at + )} | Seller ${payload?.seller_name || "Unavailable"}` + ); + } + setPhase("report"); +} + +function updateGenerateReportButton(payload) { + if (!generateReportButton) { + return; + } + + const reports = payload?.reports || []; + const canGenerate = reports.some( + (report) => + Boolean(report.source_url) || + getRankingSnapshots(report).length > 0 || + collectDiscoveredCandidates(report).length > 0 || + (report.raw_agent_outputs || []).length > 0 + ); + + generateReportButton.disabled = !canGenerate || reportGenerationInFlight; + generateReportButton.textContent = reportGenerationInFlight + ? "Generating report..." + : defaultGenerateReportButtonLabel; +} + +function hasStarted(status) { + return !["pending"].includes(String(status || "pending").toLowerCase()); +} + +function combineStates(states) { + const normalizedStates = states.map((status) => String(status || "pending").toLowerCase()); + if (normalizedStates.some((status) => status === "failed")) { + return "failed"; + } + if (normalizedStates.some((status) => status === "delayed")) { + return "delayed"; + } + if (normalizedStates.some((status) => status === "running")) { + return "running"; + } + if (normalizedStates.every((status) => status === "completed")) { + return "completed"; + } + if (normalizedStates.some((status) => status === "queued")) { + return "queued"; + } + return "pending"; +} + +function deriveTimelineStates(stepStates) { + const candidateState = stepStates.candidate_discovery || "pending"; + const triageState = stepStates.candidate_triage || "pending"; + const rankingStarted = hasStarted(stepStates.ranking) || hasStarted(stepStates.research_summary); + + return { + source: stepStates.source_extraction || "pending", + search: candidateState, + candidates: rankingStarted || hasStarted(stepStates.product_comparison) + ? "completed" + : combineStates([candidateState, triageState]), + analysis: rankingStarted + ? "completed" + : combineStates([ + stepStates.product_comparison, + stepStates.evidence, + stepStates.reasoning_enrichment, + ]), + ranking: stepStates.ranking || "pending", + }; +} + +function updateCaseGenerateReportButton(payload) { + if (!caseGenerateReportButton) { + return; + } + + const canGenerate = + Boolean(payload?.selected_listing) || + (payload?.suspect_listings || []).length > 0 || + (payload?.evidence || []).length > 0 || + Boolean(payload?.action_request_draft) || + (payload?.raw_agent_outputs || []).length > 0; + + caseGenerateReportButton.disabled = !canGenerate || caseReportGenerationInFlight; + caseGenerateReportButton.textContent = caseReportGenerationInFlight + ? "Generating report..." + : defaultCaseGenerateReportButtonLabel; +} + +function getFocusedTimelineStage(timelineStates) { + const orderedStages = timelineStageDefinitions.map((stage) => [ + stage.key, + timelineStates[stage.key] || "pending", + ]); + + for (const status of ["failed", "delayed", "running", "queued"]) { + const activeStage = orderedStages.find(([, stageStatus]) => stageStatus === status); + if (activeStage) { + return activeStage[0]; + } + } + + const firstPendingIndex = orderedStages.findIndex(([, stageStatus]) => stageStatus === "pending"); + if (firstPendingIndex === 0) { + return orderedStages[0][0]; + } + if (firstPendingIndex > 0) { + return orderedStages[firstPendingIndex - 1][0]; + } + + return orderedStages[orderedStages.length - 1][0]; +} + +function setFocusedTimelineStage(stageKey, options = {}) { + const stageIndex = timelineStageDefinitions.findIndex((stage) => stage.key === stageKey); + if (stageIndex === -1) { + return; + } + + const shouldJump = options.immediate || !timelineTrack || !timelineTrack.dataset.ready; + const stageChanged = activeTimelineStage !== stageKey; + activeTimelineStage = stageKey; + + if (timelineTrack) { + if (shouldJump) { + const previousTransition = timelineTrack.style.transition; + timelineTrack.style.transition = "none"; + timelineTrack.style.transform = `translateY(-${stageIndex * 100}%)`; + void timelineTrack.offsetHeight; + timelineTrack.style.transition = previousTransition; + } else if (stageChanged) { + timelineTrack.style.transform = `translateY(-${stageIndex * 100}%)`; + } + timelineTrack.dataset.ready = "true"; + } + + timelineStageDefinitions.forEach((stage) => { + const isActive = stage.key === stageKey; + timelineStageItems[stage.key]?.classList.toggle("is-active", isActive); + timelineRailItems[stage.key]?.classList.toggle("is-active", isActive); + }); +} + +function getSourcePreviewUrl(report) { + return report?.source_url || lastSubmittedSourceUrl || parseLines(sourceUrlsInput.value)[0] || ""; +} + +function getCandidateTasks(report) { + return (report?.raw_agent_outputs || []).filter((task) => task.agent_name === "candidate_discovery"); +} + +function getComparisonTasks(report) { + return (report?.raw_agent_outputs || []).filter((task) => task.agent_name === "product_comparison"); +} + +function getEvidenceTasks(report) { + return (report?.raw_agent_outputs || []).filter((task) => task.agent_name === "evidence"); +} + +function getRankingTask(report) { + return (report?.raw_agent_outputs || []).find((task) => task.agent_name === "ranking") || null; +} + +function hasCompletedRanking(report) { + return getRankingTask(report)?.status === "completed"; +} + +function collectDiscoveredCandidates(report) { + const candidatesByUrl = new Map(); + + getCandidateTasks(report).forEach((task) => { + (task.output_payload?.candidates || []).forEach((candidate) => { + if (!candidatesByUrl.has(candidate.product_url)) { + candidatesByUrl.set(candidate.product_url, { + ...candidate, + discovery_query: task.output_payload?.search_query || task.input_payload?.search_query || "", + comparison_site: + task.output_payload?.comparison_site || task.input_payload?.comparison_site || "", + }); + } + }); + }); + + return [...candidatesByUrl.values()]; +} + +function collectCompletedComparisons(report) { + const evidenceByUrl = new Map(); + getEvidenceTasks(report).forEach((task) => { + const productUrl = task.input_payload?.product_url; + if (productUrl && task.output_payload?.evidence) { + evidenceByUrl.set(productUrl, task.output_payload.evidence); + } + }); + + return getComparisonTasks(report) + .filter((task) => task.output_payload?.comparison) + .map((task) => { + const comparison = { ...task.output_payload.comparison }; + if ((!comparison.evidence || comparison.evidence.length === 0) && evidenceByUrl.has(comparison.product_url)) { + comparison.evidence = evidenceByUrl.get(comparison.product_url); + } + return comparison; + }); +} + +function getRankingSnapshots(report) { + if (report?.top_matches?.length && hasCompletedRanking(report)) { + return sortMatchesByCounterfeitRisk(report.top_matches); + } + if (!getRankingTask(report)) { + return []; + } + return sortMatchesByCounterfeitRisk(collectCompletedComparisons(report)); +} + +function parseLines(value) { + return value + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function setPhase(phase) { + currentPhase = phase; + body.dataset.phase = phase; +} + +function getInvestigationPollIntervalMs() { + return 1200; +} + +function getCasePollIntervalMs() { + return 1400; +} + +function setHistoryMenuOpen(isOpen) { + if (!historyButton || !historyDropdown) { + return; + } + + historyButton.setAttribute("aria-expanded", String(isOpen)); + historyDropdown.hidden = !isOpen; +} + +function setCaseHistoryMenuOpen(isOpen) { + if (!caseHistoryButton || !caseHistoryDropdown) { + return; + } + + caseHistoryButton.setAttribute("aria-expanded", String(isOpen)); + caseHistoryDropdown.hidden = !isOpen; +} + +function setComposerInvalid(isInvalid) { + promptComposer.classList.toggle("is-invalid", isInvalid); +} + +function syncPromptHeight() { + sourceUrlsInput.style.height = "0px"; + sourceUrlsInput.style.height = `${Math.min(sourceUrlsInput.scrollHeight, 240)}px`; +} + +function setStatus(status) { + const normalizedStatus = String(status || "idle").toLowerCase(); + statusPill.dataset.status = normalizedStatus; + statusPill.textContent = statusLabels[normalizedStatus] || status; +} + +function setCaseStatus(status) { + if (!caseStatusPill) { + return; + } + const normalizedStatus = String(status || "idle").toLowerCase(); + caseStatusPill.dataset.status = normalizedStatus; + caseStatusPill.textContent = statusLabels[normalizedStatus] || status; +} + +function setSubmitting(isSubmitting) { + runButton.disabled = isSubmitting; + runButton.setAttribute("aria-busy", String(isSubmitting)); + runButton.textContent = isSubmitting ? "Investigating..." : defaultRunButtonLabel; +} + +function getPersistedInvestigationId() { + try { + return window.localStorage.getItem(persistedInvestigationStorageKey); + } catch { + return null; + } +} + +function persistInvestigationId(investigationId) { + try { + window.localStorage.setItem(persistedInvestigationStorageKey, investigationId); + } catch { + // Ignore local storage failures and keep the live in-memory flow working. + } +} + +function clearPersistedInvestigationId() { + try { + window.localStorage.removeItem(persistedInvestigationStorageKey); + } catch { + // Ignore local storage failures and keep the live in-memory flow working. + } + currentInvestigationId = null; + renderPastRuns(pastRunsCache); +} + +function getPersistedCaseId() { + try { + return window.localStorage.getItem(persistedCaseStorageKey); + } catch { + return null; + } +} + +function persistCaseId(caseId) { + try { + window.localStorage.setItem(persistedCaseStorageKey, caseId); + } catch { + // Ignore local storage failures and keep the live in-memory flow working. + } +} + +function clearPersistedCaseId() { + try { + window.localStorage.removeItem(persistedCaseStorageKey); + } catch { + // Ignore local storage failures and keep the live in-memory flow working. + } + currentCaseId = null; + renderPastCases(pastCasesCache); +} + +function selectCase(caseId) { + currentCaseId = caseId; + persistCaseId(caseId); + renderPastCases(pastCasesCache); +} + +function selectInvestigation(investigationId) { + currentInvestigationId = investigationId; + persistInvestigationId(investigationId); + renderPastRuns(pastRunsCache); +} + +function loadInvestigation(investigationId) { + if (!investigationId) { + return; + } + if (pollTimer) { + window.clearTimeout(pollTimer); + } + selectInvestigation(investigationId); + resetReportScene(); + setPhase("progress"); + fetchInvestigation(investigationId); +} + +function startNewInvestigation() { + if (pollTimer) { + window.clearTimeout(pollTimer); + pollTimer = null; + } + if (casePollTimer) { + window.clearTimeout(casePollTimer); + casePollTimer = null; + } + clearPersistedInvestigationId(); + clearPersistedCaseId(); + latestInvestigationPayload = null; + latestCasePayload = null; + resetReportScene(); + resetCaseWorkspace(); + setComposerInvalid(false); + setStatus("idle"); + resetProgressTracking(); + renderTimeline(null); + renderEmptyState("Add official product page URLs to compare them against live marketplace listings."); + updateGenerateReportButton(null); + setSubmitting(false); + setHistoryMenuOpen(false); + setCaseHistoryMenuOpen(false); + sourceUrlsInput.value = ""; + syncPromptHeight(); + setPhase("prompt"); + sourceUrlsInput.focus(); +} + +function sortMatchesByCounterfeitRisk(matches) { + return [...(matches || [])].sort((left, right) => { + const riskDelta = (right.counterfeit_risk_score || 0) - (left.counterfeit_risk_score || 0); + if (riskDelta !== 0) { + return riskDelta; + } + return (right.match_score || 0) - (left.match_score || 0); + }); +} + +function canBuildSellerCase() { + return Boolean(currentInvestigationId) && String(latestInvestigationPayload?.status || "").toLowerCase() === "completed"; +} + +function configureBuildCaseButton(button, report, match) { + if (!button) { + return; + } + + button.dataset.sourceUrl = report?.source_url || ""; + button.dataset.productUrl = match?.product_url || ""; + button.dataset.marketplace = match?.marketplace || ""; + button.dataset.sellerName = match?.candidate_product?.seller_name || ""; + + const isEnabled = canBuildSellerCase() && Boolean(button.dataset.sourceUrl) && Boolean(button.dataset.productUrl); + button.disabled = !isEnabled; + if (isEnabled) { + button.removeAttribute("title"); + } else { + button.title = currentInvestigationId + ? "Complete the investigation before building a seller case." + : "Start and finish an investigation before building a seller case."; + } +} + +function sortPastRuns(runs) { + return [...runs].sort((left, right) => { + const leftTime = new Date(left.created_at).getTime(); + const rightTime = new Date(right.created_at).getTime(); + return rightTime - leftTime; + }); +} + +function formatRunTimestamp(value) { + if (!value) { + return "Unknown time"; + } + + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return "Unknown time"; + } + + return timestamp.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function describeSavedUrl(urlValue) { + if (!urlValue) { + return { + title: "", + detail: "", + full: "", + }; + } + + try { + const url = new URL(urlValue); + const pathname = decodeURIComponent(url.pathname || "/").replace(/\/$/, "") || "/"; + return { + title: url.hostname.replace(/^www\./, ""), + detail: pathname === "/" ? "Homepage" : pathname, + full: url.toString(), + }; + } catch { + return { + title: String(urlValue), + detail: "", + full: String(urlValue), + }; + } +} + +function formatRunSource(run) { + const sourceUrl = run?.primary_source_url; + const sourceTitle = sanitizePlainText(run?.primary_source_title || ""); + + if (!sourceUrl) { + return { + title: sourceTitle || "Investigation", + detail: "No source URL saved", + full: "", + }; + } + + try { + const url = new URL(sourceUrl); + const pathname = decodeURIComponent(url.pathname || "/").replace(/\/$/, "") || "/"; + return { + title: sourceTitle || url.hostname.replace(/^www\./, ""), + detail: pathname === "/" ? "Homepage" : pathname, + full: url.toString(), + }; + } catch { + return { + title: sourceTitle || sourceUrl, + detail: "", + full: sourceUrl, + }; + } +} + +function formatRunMeta(run, source) { + if (run.error) { + return run.error; + } + + const parts = []; + if (source.detail) { + parts.push(source.detail); + } + parts.push(`${run.source_count || 0} source${run.source_count === 1 ? "" : "s"}`); + return parts.join(" · "); +} + +function createPastRunItem(run) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "past-run-item"; + button.dataset.investigationId = run.investigation_id; + + const header = document.createElement("div"); + header.className = "past-run-header"; + + const status = document.createElement("span"); + status.className = "past-run-status"; + status.dataset.status = String(run.status || "queued").toLowerCase(); + + const time = document.createElement("span"); + time.className = "past-run-time"; + + header.append(status, time); + + const title = document.createElement("strong"); + title.className = "past-run-title"; + + const meta = document.createElement("span"); + meta.className = "past-run-meta"; + + button.append(header, title, meta); + return button; +} + +function renderPastRuns(runs) { + if (!pastRunsNode) { + return; + } + + if (!runs || runs.length === 0) { + pastRunsNode.innerHTML = '

No saved investigations yet.

'; + return; + } + + const existingItems = new Map( + [...pastRunsNode.querySelectorAll(".past-run-item")].map((node) => [node.dataset.investigationId, node]) + ); + + runs.forEach((run) => { + const investigationId = run.investigation_id; + const source = formatRunSource(run); + + let item = existingItems.get(investigationId); + if (!item) { + item = createPastRunItem(run); + } else { + existingItems.delete(investigationId); + } + + item.classList.toggle("is-active", investigationId === currentInvestigationId); + item.setAttribute("aria-pressed", investigationId === currentInvestigationId ? "true" : "false"); + item.title = source.full || source.title; + item.querySelector(".past-run-status").dataset.status = String(run.status || "queued").toLowerCase(); + setTextContent( + item.querySelector(".past-run-status"), + statusLabels[String(run.status || "queued").toLowerCase()] || run.status + ); + setTextContent(item.querySelector(".past-run-time"), formatRunTimestamp(run.created_at)); + setTextContent(item.querySelector(".past-run-title"), source.title); + item.querySelector(".past-run-meta").dataset.tone = run.error ? "error" : "default"; + setTextContent(item.querySelector(".past-run-meta"), formatRunMeta(run, source)); + + pastRunsNode.appendChild(item); + }); + + existingItems.forEach((node) => node.remove()); +} + +function upsertPastRun(run) { + const nextRuns = [...pastRunsCache]; + const existingIndex = nextRuns.findIndex((item) => item.investigation_id === run.investigation_id); + if (existingIndex === -1) { + nextRuns.push(run); + } else { + nextRuns[existingIndex] = { ...nextRuns[existingIndex], ...run }; + } + pastRunsCache = sortPastRuns(nextRuns); + renderPastRuns(pastRunsCache); +} + +function upsertPastRunFromInvestigation(payload) { + const existingRun = pastRunsCache.find((item) => item.investigation_id === payload.investigation_id) || null; + const nextRun = { + investigation_id: payload.investigation_id, + status: payload.status, + primary_source_url: payload.reports?.[0]?.source_url || existingRun?.primary_source_url || null, + primary_source_title: + payload.reports?.[0]?.extracted_source_product?.product_name || + payload.reports?.[0]?.extracted_source_product?.model || + payload.reports?.[0]?.extracted_source_product?.brand || + existingRun?.primary_source_title || + null, + source_count: payload.reports?.length || existingRun?.source_count || 0, + error: payload.error || null, + created_at: payload.created_at, + updated_at: payload.updated_at, + }; + upsertPastRun(nextRun); +} + +function sortPastCases(cases) { + return [...cases].sort((left, right) => { + const leftTime = new Date(left.updated_at || left.created_at).getTime(); + const rightTime = new Date(right.updated_at || right.created_at).getTime(); + return rightTime - leftTime; + }); +} + +function formatCaseSource(caseItem) { + const sellerName = sanitizePlainText(caseItem?.seller_name || ""); + const marketplace = sanitizePlainText(caseItem?.marketplace || ""); + const listing = describeSavedUrl(caseItem?.product_url); + + return { + title: sellerName || marketplace || listing.title || "Seller case", + detail: marketplace || listing.title || "Unknown marketplace", + listingDetail: listing.detail, + full: listing.full || caseItem?.product_url || caseItem?.source_url || "", + }; +} + +function formatCaseMeta(caseItem, source) { + if (caseItem.error) { + return caseItem.error; + } + + const sourceReference = describeSavedUrl(caseItem?.source_url); + const parts = []; + if (source.detail) { + parts.push(source.detail); + } + if (source.listingDetail && source.listingDetail !== "Homepage") { + parts.push(source.listingDetail); + } + if (sourceReference.title) { + parts.push(`Source ${sourceReference.title}`); + } + return parts.join(" · "); +} + +function createPastCaseItem(caseItem) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "past-run-item"; + button.dataset.caseId = caseItem.case_id; + + const header = document.createElement("div"); + header.className = "past-run-header"; + + const status = document.createElement("span"); + status.className = "past-run-status"; + status.dataset.status = String(caseItem.status || "queued").toLowerCase(); + + const time = document.createElement("span"); + time.className = "past-run-time"; + + header.append(status, time); + + const title = document.createElement("strong"); + title.className = "past-run-title"; + + const meta = document.createElement("span"); + meta.className = "past-run-meta"; + + button.append(header, title, meta); + return button; +} + +function renderPastCases(cases) { + if (!pastCasesNode) { + return; + } + + if (!cases || cases.length === 0) { + pastCasesNode.innerHTML = '

No saved seller cases yet.

'; + return; + } + + const existingItems = new Map( + [...pastCasesNode.querySelectorAll(".past-run-item")].map((node) => [node.dataset.caseId, node]) + ); + + cases.forEach((caseItem) => { + const caseId = caseItem.case_id; + const source = formatCaseSource(caseItem); + + let item = existingItems.get(caseId); + if (!item) { + item = createPastCaseItem(caseItem); + } else { + existingItems.delete(caseId); + } + + item.classList.toggle("is-active", caseId === currentCaseId); + item.setAttribute("aria-pressed", caseId === currentCaseId ? "true" : "false"); + item.title = source.full || source.title; + item.querySelector(".past-run-status").dataset.status = String(caseItem.status || "queued").toLowerCase(); + setTextContent( + item.querySelector(".past-run-status"), + statusLabels[String(caseItem.status || "queued").toLowerCase()] || caseItem.status + ); + setTextContent(item.querySelector(".past-run-time"), formatRunTimestamp(caseItem.updated_at || caseItem.created_at)); + setTextContent(item.querySelector(".past-run-title"), source.title); + item.querySelector(".past-run-meta").dataset.tone = caseItem.error ? "error" : "default"; + setTextContent(item.querySelector(".past-run-meta"), formatCaseMeta(caseItem, source)); + + pastCasesNode.appendChild(item); + }); + + existingItems.forEach((node) => node.remove()); +} + +function upsertPastCase(caseItem) { + const nextCases = [...pastCasesCache]; + const existingIndex = nextCases.findIndex((item) => item.case_id === caseItem.case_id); + if (existingIndex === -1) { + nextCases.push(caseItem); + } else { + nextCases[existingIndex] = { ...nextCases[existingIndex], ...caseItem }; + } + pastCasesCache = sortPastCases(nextCases); + renderPastCases(pastCasesCache); +} + +function upsertPastCaseFromCasePayload(payload) { + const existingCase = pastCasesCache.find((item) => item.case_id === payload.case_id) || null; + const nextCase = { + case_id: payload.case_id, + status: payload.status, + seller_name: payload.seller_name || existingCase?.seller_name || null, + marketplace: + payload.marketplace || + payload.selected_listing?.marketplace || + existingCase?.marketplace || + null, + source_url: payload.source_url || existingCase?.source_url || "", + product_url: payload.product_url || existingCase?.product_url || "", + error: payload.error || null, + created_at: payload.created_at || existingCase?.created_at, + updated_at: payload.updated_at || existingCase?.updated_at || payload.created_at, + }; + upsertPastCase(nextCase); +} + +async function refreshPastRuns() { + if (!pastRunsNode) { + return; + } + + try { + const response = await fetch("/investigations?limit=12"); + if (!response.ok) { + throw new Error("Unable to load investigation history."); + } + pastRunsCache = sortPastRuns(await response.json()); + renderPastRuns(pastRunsCache); + } catch { + if (pastRunsCache.length === 0) { + pastRunsNode.innerHTML = + '

Saved investigations could not be loaded right now.

'; + } + } +} + +async function refreshPastCases() { + if (!pastCasesNode) { + return; + } + + try { + const response = await fetch("/cases?limit=12"); + if (!response.ok) { + throw new Error("Unable to load seller case history."); + } + pastCasesCache = sortPastCases(await response.json()); + renderPastCases(pastCasesCache); + } catch { + if (pastCasesCache.length === 0) { + pastCasesNode.innerHTML = '

Saved seller cases could not be loaded right now.

'; + } + } +} + +function renderEmptyState(message) { + if (!resultsNode) { + return; + } + resultsNode.innerHTML = `

${message}

`; +} + +function setTextContent(node, value) { + const nextValue = value ?? ""; + if (node.textContent !== nextValue) { + node.textContent = nextValue; + } +} + +function setInnerHtml(node, value) { + const nextValue = value ?? ""; + if (node.dataset.renderedHtml !== nextValue) { + node.innerHTML = nextValue; + node.dataset.renderedHtml = nextValue; + } +} + +function updateProgressUI({ overview, detail, percent, stepStates, timelineStates, focusedStage }) { + progressOverview.textContent = overview; + progressText.textContent = detail; + progressFill.style.width = `${percent}%`; + progressTrack.setAttribute("aria-valuenow", String(percent)); + + const nextTimelineStates = timelineStates || deriveTimelineStates(stepStates); + timelineStageDefinitions.forEach((stage) => { + const node = timelineStageItems[stage.key]; + const railNode = timelineRailItems[stage.key]; + const status = nextTimelineStates[stage.key] || "pending"; + node.dataset.status = status; + node.querySelector(".timeline-step-state").textContent = timelineStateLabels[status] || status; + if (railNode) { + railNode.dataset.status = status; + } + }); + + setFocusedTimelineStage(focusedStage || getFocusedTimelineStage(nextTimelineStates)); +} + +function resetProgressTracking() { + const stepStates = Object.fromEntries(progressStepDefinitions.map((step) => [step.key, "pending"])); + updateProgressUI({ + overview: "No investigation running yet.", + detail: "Waiting for an investigation to start.", + percent: 0, + stepStates, + }); + setFocusedTimelineStage("source", { immediate: true }); + renderTimeline(null); +} + +function getActiveTask(report) { + const tasks = report?.raw_agent_outputs || []; + return ( + [...tasks].reverse().find((task) => task.status === "delayed") || + [...tasks].reverse().find((task) => task.status === "running") || + [...tasks].reverse().find((task) => task.status === "failed") || + [...tasks].reverse()[0] || + null + ); +} + +function formatRelativeTime(value) { + if (!value) { + return null; + } + + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + const diffSeconds = Math.max(0, Math.round((Date.now() - timestamp.getTime()) / 1000)); + if (diffSeconds < 60) { + return `${diffSeconds}s ago`; + } + if (diffSeconds < 3600) { + return `${Math.round(diffSeconds / 60)}m ago`; + } + return `${Math.round(diffSeconds / 3600)}h ago`; +} + +function describeProviderState(task) { + if (!task) { + return ""; + } + + const parts = []; + if (task.provider_status) { + parts.push(`TinyFish ${task.provider_status}`); + } + + const heartbeat = formatRelativeTime(task.last_heartbeat_at); + if (heartbeat) { + parts.push(`heartbeat ${heartbeat}`); + } + + const progress = formatRelativeTime(task.last_progress_at); + if (progress && task.last_progress_at !== task.last_heartbeat_at) { + parts.push(`last material update ${progress}`); + } + + if (task.provider_run_id) { + parts.push(`run ${String(task.provider_run_id).slice(0, 8)}`); + } + + return parts.join(" · "); +} + +function deriveReportStepStates(report) { + const tasks = report?.raw_agent_outputs || []; + + return Object.fromEntries( + progressStepDefinitions.map((step, stepIndex) => { + const matchingTasks = tasks.filter((task) => task.agent_name === step.key); + const laterStepStarted = tasks.some( + (task) => (progressStepIndex[task.agent_name] ?? -1) > stepIndex + ); + + if (matchingTasks.some((task) => task.status === "failed")) { + return [step.key, "failed"]; + } + if (matchingTasks.some((task) => task.status === "delayed")) { + return [step.key, "delayed"]; + } + if (matchingTasks.some((task) => task.status === "running")) { + return [step.key, "running"]; + } + if (matchingTasks.length > 0 && matchingTasks.every((task) => task.status === "completed")) { + return [step.key, "completed"]; + } + if (laterStepStarted) { + return [step.key, "completed"]; + } + if (tasks.length === 0 && stepIndex === 0) { + return [step.key, "queued"]; + } + return [step.key, "pending"]; + }) + ); +} + +function getActiveReportIndex(reports) { + const runningIndex = reports.findIndex((report) => + (report.raw_agent_outputs || []).some((task) => ["running", "delayed"].includes(task.status)) + ); + if (runningIndex !== -1) { + return runningIndex; + } + + const failedIndex = reports.findIndex((report) => + report.error || (report.raw_agent_outputs || []).some((task) => task.status === "failed") + ); + if (failedIndex !== -1) { + return failedIndex; + } + + const nextQueuedIndex = reports.findIndex((report) => (report.raw_agent_outputs || []).length === 0); + if (nextQueuedIndex === 0) { + return 0; + } + if (nextQueuedIndex > 0) { + return nextQueuedIndex - 1; + } + + return Math.max(reports.length - 1, 0); +} + +function isReportComplete(report) { + const stepStates = deriveReportStepStates(report); + return progressStepDefinitions.every((step) => stepStates[step.key] === "completed"); +} + +function calculateProgressPercent(reports, investigationStatus) { + if (!reports.length) { + return investigationStatus === "queued" ? 4 : 0; + } + + const totalUnits = reports.length * progressStepDefinitions.length; + let completedUnits = 0; + + reports.forEach((report) => { + const stepStates = deriveReportStepStates(report); + completedUnits += progressStepDefinitions.filter( + (step) => stepStates[step.key] === "completed" + ).length; + if ( + progressStepDefinitions.some((step) => + ["running", "delayed"].includes(stepStates[step.key]) + ) + ) { + completedUnits += 0.5; + } + }); + + if (investigationStatus === "completed") { + return 100; + } + + return Math.max(0, Math.min(99, Math.round((completedUnits / totalUnits) * 100))); +} + +function renderProgressTracking(payload) { + const reports = payload.reports || []; + const activeReport = reports[getActiveReportIndex(reports)] || null; + const activeTask = getActiveTask(activeReport); + const activeStepStates = activeReport + ? deriveReportStepStates(activeReport) + : Object.fromEntries(progressStepDefinitions.map((step) => [step.key, "pending"])); + const timelineStates = deriveTimelineStates(activeStepStates); + const focusedTimelineStage = getFocusedTimelineStage(timelineStates); + const focusedTimelineLabel = + timelineStageDefinitions.find((stage) => stage.key === focusedTimelineStage)?.label || + "Investigation"; + const activeStep = + progressStepDefinitions.find((step) => activeStepStates[step.key] === "delayed") || + progressStepDefinitions.find((step) => activeStepStates[step.key] === "running") || + progressStepDefinitions.find((step) => activeStepStates[step.key] === "failed") || + progressStepDefinitions.find((step) => activeStepStates[step.key] === "queued") || + progressStepDefinitions.find((step) => activeStepStates[step.key] === "pending"); + const completedReports = reports.filter(isReportComplete).length; + const sourcePosition = activeReport ? getActiveReportIndex(reports) + 1 : 0; + const totalSources = reports.length; + + let overview = "No investigation running yet."; + let detail = "Waiting for an investigation to start."; + + if (payload.status === "queued") { + overview = totalSources > 1 ? `Source 1 of ${totalSources} · ${focusedTimelineLabel}` : focusedTimelineLabel; + detail = activeReport?.summary || "Preparing the investigation context."; + } else if (payload.status === "running") { + overview = + totalSources > 1 + ? `Source ${sourcePosition} of ${totalSources} · ${focusedTimelineLabel}` + : focusedTimelineLabel; + detail = activeReport?.summary || "Investigation is in progress."; + } else if (payload.status === "delayed") { + overview = + totalSources > 1 + ? `Source ${sourcePosition} of ${totalSources} · ${focusedTimelineLabel}` + : focusedTimelineLabel; + detail = activeReport?.summary || "TinyFish is still working on the active step."; + } else if (payload.status === "completed") { + overview = + totalSources > 1 + ? `Completed ${completedReports} of ${totalSources} · ${focusedTimelineLabel}` + : focusedTimelineLabel; + detail = activeReport?.summary || "The investigation finished successfully."; + } else if (payload.status === "failed") { + overview = focusedTimelineLabel; + detail = payload.error || activeReport?.error || "The investigation ended with an error."; + } + + if (activeStep?.label && payload.status !== "completed") { + detail = `${activeStep.label}. ${detail}`; + } + + const providerState = describeProviderState(activeTask); + if (providerState) { + detail = `${detail} ${detail.endsWith(".") ? "" : "."} ${providerState}`; + } + + updateProgressUI({ + overview, + detail, + percent: calculateProgressPercent(reports, payload.status), + stepStates: activeStepStates, + timelineStates, + focusedStage: focusedTimelineStage, + }); + + renderTimeline(activeReport); +} + +function renderSourceStage(report) { + const sourceUrl = getSourcePreviewUrl(report); + const product = report?.extracted_source_product || null; + + setTextContent(timelineSourceUrl, sourceUrl || "No source URL selected yet."); + timelineSourceLink.hidden = !sourceUrl; + if (sourceUrl) { + timelineSourceLink.href = sourceUrl; + if (timelineSourceFrame.dataset.sourceUrl !== sourceUrl) { + timelineSourceFrame.src = sourceUrl; + timelineSourceFrame.dataset.sourceUrl = sourceUrl; + } + } else if (timelineSourceFrame.dataset.sourceUrl) { + timelineSourceFrame.removeAttribute("src"); + timelineSourceFrame.dataset.sourceUrl = ""; + } + + if (!sourceUrl) { + setTextContent(timelineNotes.source, "Waiting for the official product page."); + setInnerHtml( + timelineSourceMeta, + '

The source page will appear here after you start an investigation.

' + ); + return; + } + + const metaHtml = product + ? ` + Extracted profile +
${escapeHtml( + `${product.brand || "Unknown brand"} · ${product.product_name || "Unknown product"}` + )}
+
+
Category${escapeHtml(product.category || "Unavailable")}
+
SKU${escapeHtml(product.sku || "Unavailable")}
+
Model${escapeHtml(product.model || "Unavailable")}
+
Price${escapeHtml( + product.price !== null && product.price !== undefined + ? formatCompactCurrency(product.price, product.currency) + : "Unavailable" + )}
+
+ ` + : ` + Source status +
${escapeHtml(formatHostname(sourceUrl))}
+

Extracted source attributes will populate here once the source step finishes.

+ `; + + setTextContent( + timelineNotes.source, + product + ? "Official product details extracted from the source page." + : "Showing the live source page while extraction is still running." + ); + setInnerHtml(timelineSourceMeta, metaHtml); +} + +function renderSearchStage(report) { + const searchTasks = getCandidateTasks(report); + const visibleSearchTasks = searchTasks.slice(0, 3); + + if (searchTasks.length === 0) { + setTextContent( + timelineNotes.search, + "Search queries will appear here as TinyFish fans out across marketplaces." + ); + setInnerHtml( + timelineSearchLog, + '

No marketplace queries have started yet.

' + ); + return; + } + + setTextContent( + timelineNotes.search, + `Tracking ${searchTasks.length} marketplace quer${searchTasks.length === 1 ? "y" : "ies"} live.` + ); + + setInnerHtml( + timelineSearchLog, + visibleSearchTasks + .map((task) => { + const query = + task.output_payload?.search_query || task.input_payload?.search_query || "Waiting for query"; + const comparisonSite = + task.output_payload?.comparison_site || task.input_payload?.comparison_site || ""; + const candidateCount = task.output_payload?.candidate_count; + const runtime = task.output_payload?.runtime || {}; + const duration = formatElapsedSeconds(runtime.tinyfish_elapsed_seconds); + const rightLabel = + candidateCount !== undefined + ? `${candidateCount} hit${candidateCount === 1 ? "" : "s"}` + : progressStateLabels[task.status] || task.status; + + return ` +
+
+ ${escapeHtml(query)} + ${escapeHtml(rightLabel)} +
+

+ ${escapeHtml(formatHostname(comparisonSite))} + ${duration ? ` · ${escapeHtml(duration)} elapsed` : ""} + ${describeProviderState(task) ? ` · ${escapeHtml(describeProviderState(task))}` : ""} +

+
+ `; + }) + .join("") + ); +} + +function renderCandidateStage(report) { + const candidates = collectDiscoveredCandidates(report); + const visibleCandidates = candidates.slice(0, 4); + + if (candidates.length === 0) { + setTextContent( + timelineNotes.candidates, + "Candidate listings will stream in as search results are captured." + ); + setInnerHtml( + timelineCandidateStream, + '

No candidate listings have been captured yet.

' + ); + return; + } + + setTextContent( + timelineNotes.candidates, + `${candidates.length} unique candidate listing${candidates.length === 1 ? "" : "s"} captured so far.` + ); + + setInnerHtml( + timelineCandidateStream, + visibleCandidates + .map((candidate) => { + const price = + candidate.price !== null && candidate.price !== undefined + ? formatCompactCurrency(candidate.price, candidate.currency) + : "Price unavailable"; + return ` +
+
+ ${escapeHtml( + candidate.marketplace || formatHostname(candidate.product_url) + )} + ${escapeHtml(candidate.discovery_query || "live query")} +
+

${escapeHtml( + candidate.title || candidate.model || candidate.product_url + )}

+ +
+ ${escapeHtml(price)} + ${escapeHtml(candidate.sku || "No SKU")} +
+
+ `; + }) + .join("") + ); +} + +function getComparisonThreads(report) { + return collectCompletedComparisons(report).map((comparison) => { + const fields = [ + ...(comparison.evidence || []).map((item) => item.field), + ...(comparison.suspicious_signals || []), + ...(comparison.official_store_signals || []), + ].filter(Boolean); + + return { + ...comparison, + fields: [...new Set(fields)].slice(0, 4), + }; + }); +} + +function renderAnalysisStage(report) { + const threads = getComparisonThreads(report); + const visibleThreads = threads.slice(0, 1); + const activeTasks = getComparisonTasks(report).filter( + (task) => !task.output_payload?.comparison || ["running", "delayed", "failed"].includes(task.status) + ); + const visibleActiveTasks = activeTasks.slice(0, 3); + const sourceProduct = report?.extracted_source_product || null; + + if (threads.length === 0) { + setTextContent( + timelineNotes.analysis, + "Comparison signals will assemble here once candidate pages are inspected." + ); + setInnerHtml( + timelineSignalGraph, + ` +
+ Source +
${escapeHtml(sourceProduct?.product_name || "Waiting for extracted source product")}
+
+

No comparison graph is available yet.

+ ` + ); + } else { + setTextContent( + timelineNotes.analysis, + `Built ${threads.length} reasoning thread${threads.length === 1 ? "" : "s"} from completed comparisons.` + ); + setInnerHtml( + timelineSignalGraph, + ` +
+ Source +
${escapeHtml( + sourceProduct?.product_name || sourceProduct?.model || report?.source_url || "Source product" + )}
+
+ ${visibleThreads + .map( + (thread) => ` +
+ +
+ ${escapeHtml(thread.marketplace || formatHostname(thread.product_url))} +
${escapeHtml( + thread.candidate_product?.title || thread.candidate_product?.model || thread.product_url + )}
+
+

${escapeHtml(formatReasonText(thread.reason || "No explanation returned."))}

+
+ ` + ) + .join("")} + ` + ); + } + + if (threads.length === 0 && activeTasks.length === 0) { + setInnerHtml( + timelineAnalysisLog, + '

Reasoning traces will appear here once comparison begins.

' + ); + return; + } + + const logItems = + threads.length > 0 + ? visibleThreads.map( + (thread) => ` +
+
+ ${escapeHtml( + thread.candidate_product?.title || thread.product_url + )} + Risk ${escapeHtml( + Number(thread.counterfeit_risk_score || 0).toFixed(1) + )} +
+

${escapeHtml( + formatReasonText(thread.reason || "No explanation returned.") + )}

+
+ ` + ) + : visibleActiveTasks.map( + (task) => ` +
+
+ ${escapeHtml( + formatHostname(task.input_payload?.product_url || "candidate listing") + )} + ${escapeHtml(progressStateLabels[task.status] || task.status)} +
+

${escapeHtml( + describeProviderState(task) || "TinyFish is still inspecting this listing." + )}

+
+ ` + ); + + setInnerHtml(timelineAnalysisLog, logItems.join("")); +} + +function createRankingItem(productUrl) { + const item = document.createElement("li"); + item.className = "ranking-item"; + item.dataset.productUrl = productUrl; + + const rank = document.createElement("span"); + rank.className = "ranking-rank"; + const main = document.createElement("div"); + main.className = "ranking-main"; + const title = document.createElement("p"); + title.className = "ranking-title"; + const url = document.createElement("a"); + url.className = "ranking-url ranking-url-link"; + url.target = "_blank"; + url.rel = "noreferrer"; + const metadata = document.createElement("div"); + metadata.className = "ranking-metadata"; + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "ranking-toggle"; + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-label", "Show reasoning"); + const chevron = document.createElement("span"); + chevron.className = "ranking-chevron"; + chevron.setAttribute("aria-hidden", "true"); + toggle.appendChild(chevron); + const detailBody = document.createElement("div"); + detailBody.className = "ranking-details-body"; + detailBody.hidden = true; + + main.append(title, url, metadata); + item.append(rank, main, toggle, detailBody); + return item; +} + +function renderRankingStage(report) { + const rankingItems = getRankingSnapshots(report).slice(0, 5); + + if (rankingItems.length === 0) { + setTextContent(timelineNotes.ranking, "Matches will settle into rank as scores firm up."); + setInnerHtml( + timelineRankingList, + '
  • No ranked matches are available yet.
  • ' + ); + return; + } + + setTextContent( + timelineNotes.ranking, + report?.top_matches?.length + ? `Ranking finalized across ${rankingItems.length} suspicious match${rankingItems.length === 1 ? "" : "es"}.` + : `Showing a provisional order from ${rankingItems.length} completed comparison${rankingItems.length === 1 ? "" : "s"}.` + ); + + const previousPositions = new Map( + [...timelineRankingList.querySelectorAll(".ranking-item")].map((node) => [ + node.dataset.productUrl, + node.getBoundingClientRect(), + ]) + ); + const existingItems = new Map( + [...timelineRankingList.querySelectorAll(".ranking-item")].map((node) => [node.dataset.productUrl, node]) + ); + [...timelineRankingList.querySelectorAll(".empty-state")].forEach((node) => node.remove()); + + rankingItems.forEach((match, index) => { + const productUrl = String(match.product_url); + let item = existingItems.get(productUrl); + if (!item) { + item = createRankingItem(productUrl); + } else { + existingItems.delete(productUrl); + } + + setTextContent(item.querySelector(".ranking-rank"), String(index + 1).padStart(2, "0")); + setTextContent( + item.querySelector(".ranking-title"), + match.candidate_product?.title || match.candidate_product?.model || productUrl + ); + const urlNode = item.querySelector(".ranking-url"); + urlNode.href = productUrl; + setTextContent(urlNode, productUrl); + setInnerHtml( + item.querySelector(".ranking-metadata"), + ` + + Risk + + ${escapeHtml(Number(match.counterfeit_risk_score || 0).toFixed(1))} + + + + Match + ${escapeHtml(Number(match.match_score || 0).toFixed(1))} + + + ` + ); + setInnerHtml( + item.querySelector(".ranking-details-body"), + ` +

    ${escapeHtml(formatReasonText(match.reason || "No reasoning returned."))}

    +
    + Risk reasoning + +
    +
    + Match reasoning + +
    + ${ + match.suspicious_signals?.length + ? ` +
    + Signals +
    + ${match.suspicious_signals + .map((signal) => `${escapeHtml(humanizeFieldName(signal))}`) + .join("")} +
    +
    + ` + : "" + } + ${ + match.evidence?.length + ? ` +
    + Evidence +
    + ${match.evidence + .slice(0, 3) + .map( + (evidenceItem) => ` +
    + ${escapeHtml(evidenceItem.field || "Field")} +

    ${escapeHtml(evidenceItem.note || "No note returned.")}

    +
    + ` + ) + .join("")} +
    +
    + ` + : "" + } + ` + ); + configureBuildCaseButton(item.querySelector("[data-build-case]"), report, match); + const toggleButton = item.querySelector(".ranking-toggle"); + const isOpen = item.classList.contains("is-open"); + toggleButton.setAttribute("aria-expanded", String(isOpen)); + toggleButton.setAttribute("aria-label", isOpen ? "Hide reasoning" : "Show reasoning"); + item.querySelector(".ranking-details-body").hidden = !isOpen; + + timelineRankingList.appendChild(item); + }); + + existingItems.forEach((node) => node.remove()); + + requestAnimationFrame(() => { + [...timelineRankingList.querySelectorAll(".ranking-item")].forEach((node) => { + const previousPosition = previousPositions.get(node.dataset.productUrl); + if (!previousPosition) { + node.animate( + [{ opacity: 0, transform: "translateY(14px)" }, { opacity: 1, transform: "translateY(0)" }], + { duration: 420, easing: "cubic-bezier(0.22, 1, 0.36, 1)" } + ); + return; + } + + const nextPosition = node.getBoundingClientRect(); + const deltaY = previousPosition.top - nextPosition.top; + if (deltaY) { + node.animate( + [{ transform: `translateY(${deltaY}px)` }, { transform: "translateY(0)" }], + { duration: 620, easing: "cubic-bezier(0.22, 1, 0.36, 1)" } + ); + } + }); + }); +} + +function renderTimeline(activeReport) { + renderSourceStage(activeReport); + renderSearchStage(activeReport); + renderCandidateStage(activeReport); + renderAnalysisStage(activeReport); + renderRankingStage(activeReport); +} + +function formatSourceProduct(product, error) { + if (error) { + return `Extraction failed: ${error}`; + } + if (!product) { + return "No source product extracted."; + } + return ` + ${product.brand || "Unknown brand"} · ${product.product_name || "Unknown product"}
    + SKU: ${product.sku || "n/a"}
    + Model: ${product.model || "n/a"}
    + Price: ${product.currency || ""} ${product.price || "n/a"}
    + Material: ${product.material || "n/a"}
    + Features: ${(product.features || []).join(", ") || "n/a"} + `; +} + +function getReportKey(report, index) { + return `${index}:${report.source_url}`; +} + +function createReportCard(reportKey) { + const reportFragment = reportTemplate.content.cloneNode(true); + const reportCard = reportFragment.querySelector(".report-card"); + reportCard.dataset.reportKey = reportKey; + return reportCard; +} + +function renderMatches(matchesNode, topMatches, report) { + const sortedMatches = sortMatchesByCounterfeitRisk(topMatches); + const matchesFingerprint = JSON.stringify(sortedMatches); + if (matchesNode.dataset.renderedMatches === matchesFingerprint) { + return; + } + + matchesNode.dataset.renderedMatches = matchesFingerprint; + matchesNode.innerHTML = ""; + + if (!sortedMatches || sortedMatches.length === 0) { + matchesNode.innerHTML = '

    No ranked matches were returned.

    '; + return; + } + + sortedMatches.forEach((match) => { + const matchFragment = matchTemplate.content.cloneNode(true); + matchFragment.querySelector(".match-header").innerHTML = ` + ${match.marketplace}
    + ${match.product_url} + `; + matchFragment.querySelector(".score-grid").innerHTML = ` +
    Match Score${match.match_score}
    +
    Counterfeit Risk${match.counterfeit_risk_score}
    +
    Exact Match${match.is_exact_match ? "Yes" : "No"}
    + `; + matchFragment.querySelector(".reason").textContent = formatReasonText( + match.reason || "No reasoning returned." + ); + matchFragment.querySelector(".signals").innerHTML = + match.suspicious_signals.length > 0 + ? match.suspicious_signals + .map((signal) => `${escapeHtml(humanizeFieldName(signal))}`) + .join("") + : 'No suspicious signals were flagged.'; + matchFragment.querySelector(".evidence-list").innerHTML = + match.evidence.length > 0 + ? match.evidence + .map( + (item) => ` +
    + ${item.field} · ${item.note}
    + Source: ${item.source_value ?? "n/a"}
    + Candidate: ${item.candidate_value ?? "n/a"}
    + Confidence: ${item.confidence} +
    + ` + ) + .join("") + : '

    No evidence items returned.

    '; + configureBuildCaseButton(matchFragment.querySelector("[data-build-case]"), report, match); + matchesNode.appendChild(matchFragment); + }); +} + +function createAgentLogItem(taskId) { + const item = document.createElement("div"); + item.className = "agent-log-item"; + item.dataset.taskId = taskId; + + const header = document.createElement("div"); + header.className = "agent-log-head"; + const name = document.createElement("strong"); + name.className = "agent-log-name"; + const status = document.createElement("span"); + status.className = "agent-log-status"; + header.append(name, document.createTextNode(" · "), status); + + const provider = document.createElement("div"); + provider.className = "agent-log-provider"; + + const error = document.createElement("div"); + error.className = "agent-log-error"; + + const output = document.createElement("code"); + output.className = "agent-log-output"; + + item.append(header, provider, error, output); + return item; +} + +function renderAgentLog(agentLogContent, tasks) { + const existingItems = new Map( + [...agentLogContent.querySelectorAll(".agent-log-item")].map((node) => [node.dataset.taskId, node]) + ); + + tasks.forEach((task) => { + let item = existingItems.get(task.task_id); + if (!item) { + item = createAgentLogItem(task.task_id); + } else { + existingItems.delete(task.task_id); + } + + setTextContent(item.querySelector(".agent-log-name"), task.agent_name); + setTextContent(item.querySelector(".agent-log-status"), task.status); + + const providerState = describeProviderState(task); + const providerNode = item.querySelector(".agent-log-provider"); + setTextContent(providerNode, providerState); + providerNode.hidden = !providerState; + + const errorNode = item.querySelector(".agent-log-error"); + const errorText = task.error ? `Error: ${task.error}` : ""; + setTextContent(errorNode, errorText); + errorNode.hidden = !errorText; + + setTextContent( + item.querySelector(".agent-log-output"), + JSON.stringify(task.output_payload, null, 2) + ); + + agentLogContent.appendChild(item); + }); + + existingItems.forEach((node) => node.remove()); +} + +function updateReportCard(reportCard, report) { + setTextContent(reportCard.querySelector(".report-summary"), report.summary); + setInnerHtml( + reportCard.querySelector(".report-source"), + ` + Source URL
    + ${report.source_url}

    + Extracted Product
    + ${formatSourceProduct(report.extracted_source_product, report.error)} + ` + ); + + renderMatches(reportCard.querySelector(".matches"), getRankingSnapshots(report), report); + renderAgentLog(reportCard.querySelector(".agent-log-content"), report.raw_agent_outputs || []); +} + +function renderResults(payload) { + const visibleReports = (payload.reports || []).filter( + (report) => + (report.raw_agent_outputs || []).length > 0 || + Boolean(report.extracted_source_product) || + getRankingSnapshots(report).length > 0 || + Boolean(report.error) + ); + + if (visibleReports.length === 0) { + renderEmptyState("No investigation reports are available yet."); + return; + } + + const topLevelEmptyState = [...resultsNode.children].find((child) => + child.classList.contains("empty-state") + ); + if (topLevelEmptyState) { + topLevelEmptyState.remove(); + } + + const existingCards = new Map( + [...resultsNode.querySelectorAll(".report-card")].map((node) => [node.dataset.reportKey, node]) + ); + const nextKeys = new Set(); + + visibleReports.forEach((report, index) => { + const reportKey = getReportKey(report, index); + nextKeys.add(reportKey); + + let reportCard = existingCards.get(reportKey); + if (!reportCard) { + reportCard = createReportCard(reportKey); + } + + updateReportCard(reportCard, report); + resultsNode.appendChild(reportCard); + }); + + existingCards.forEach((node, key) => { + if (!nextKeys.has(key)) { + node.remove(); + } + }); +} + +function resetCaseWorkspace() { + latestCasePayload = null; + currentCaseId = null; + setCaseStatus("idle"); + if (caseProgressFill) { + caseProgressFill.style.width = "0%"; + } + if (caseProgressTrack) { + caseProgressTrack.setAttribute("aria-valuenow", "0"); + } + setTextContent(caseTitle, "Seller Enforcement Case"); + setTextContent( + caseSubtitle, + "Select a suspicious seller from the investigation results to build a case." + ); + setTextContent(caseProgressText, "No seller case has been started yet."); + setInnerHtml( + caseProfileSummary, + '

    Seller profile details will appear here after TinyFish inspects the storefront.

    ' + ); + setInnerHtml( + caseSeedSummary, + '

    Choose a suspicious listing from the investigation results to seed a seller case.

    ' + ); + setInnerHtml( + caseSuspectListings, + '

    Suspicious seller listings will populate here after the storefront inventory is analyzed.

    ' + ); + setInnerHtml( + caseEvidenceGrid, + '

    Evidence objects will appear here after the seller-level synthesis step completes.

    ' + ); + setInnerHtml( + caseDraft, + '

    The marketplace-facing request draft will appear here after evidence is assembled.

    ' + ); + setInnerHtml( + caseActivityLog, + '

    Agent activity will stream here while the seller case is being built.

    ' + ); + if (caseAgentLog) { + caseAgentLog.innerHTML = ""; + } + updateCaseGenerateReportButton(null); +} + +function updateCaseProgress(payload) { + const tasks = payload?.raw_agent_outputs || []; + const completed = tasks.filter((task) => task.status === "completed").length; + const failed = tasks.some((task) => task.status === "failed"); + const percent = + payload?.status === "completed" + ? 100 + : payload?.status === "failed" + ? Math.max(12, Math.round((completed / Math.max(tasks.length, 1)) * 100)) + : tasks.length > 0 + ? Math.max(8, Math.round((completed / tasks.length) * 100)) + : payload?.status === "queued" + ? 4 + : 12; + + setCaseStatus(payload?.status || "idle"); + if (caseProgressFill) { + caseProgressFill.style.width = `${percent}%`; + } + if (caseProgressTrack) { + caseProgressTrack.setAttribute("aria-valuenow", String(percent)); + } + if (payload?.summary) { + setTextContent(caseProgressText, payload.summary); + } else if (failed) { + setTextContent(caseProgressText, "The seller case failed before the draft could be completed."); + } else { + setTextContent(caseProgressText, "Preparing the seller case workflow."); + } +} + +function renderCaseProfile(payload) { + const profile = payload?.seller_profile; + if (!profile) { + setInnerHtml( + caseProfileSummary, + '

    TinyFish has not returned seller profile data yet.

    ' + ); + return; + } + + const badges = (profile.badges || []) + .map((badge) => `${escapeHtml(badge)}`) + .join(""); + const officialClaims = (profile.official_store_claims || []) + .map((claim) => `${escapeHtml(claim)}`) + .join(""); + setInnerHtml( + caseProfileSummary, + ` +
    + ${escapeHtml(profile.seller_name || payload.seller_name || "Unknown seller")} +

    ${escapeHtml(profile.storefront_summary || profile.profile_text || "No storefront summary returned yet.")}

    + ${profile.seller_url ? `

    Open storefront

    ` : ""} +
    +
    +
    Marketplace${escapeHtml(profile.marketplace || payload.marketplace || "Unknown")}
    +
    Rating${profile.rating ?? "n/a"}
    +
    Ratings Count${profile.rating_count ?? "n/a"}
    +
    Followers${profile.follower_count ?? "n/a"}
    +
    Location${escapeHtml(profile.location || "n/a")}
    +
    Joined${escapeHtml(profile.joined_date || "n/a")}
    +
    Entry URLs${(profile.entry_urls || []).length}
    +
    Storefront Shards${(profile.storefront_shard_urls || []).length}
    +
    + ${badges ? `
    ${badges}
    ` : ""} + ${officialClaims ? `
    ${officialClaims}
    ` : ""} + ` + ); +} + +function renderCaseSeed(payload) { + const selectedListing = payload?.selected_listing; + if (!selectedListing) { + setInnerHtml( + caseSeedSummary, + `
    ${escapeHtml(payload?.product_url || "Selected listing")}

    Seed listing details are still being resolved from the investigation results.

    ` + ); + return; + } + + setInnerHtml( + caseSeedSummary, + ` +
    + ${escapeHtml(selectedListing.candidate_product?.title || selectedListing.product_url)} +

    ${escapeHtml(selectedListing.product_url)}

    +

    ${escapeHtml(formatReasonText(selectedListing.reason || "No case seed rationale returned."))}

    +
    +
    +
    Marketplace${escapeHtml(selectedListing.marketplace || "Unknown")}
    +
    Seller${escapeHtml(selectedListing.candidate_product?.seller_name || payload?.seller_name || "Unknown")}
    +
    Risk${selectedListing.counterfeit_risk_score ?? "n/a"}
    +
    Match${selectedListing.match_score ?? "n/a"}
    +
    + ` + ); +} + +function renderCaseListings(payload) { + const listings = payload?.suspect_listings || []; + const officialMatchesByUrl = Object.fromEntries( + (payload?.official_product_matches || []).map((item) => [String(item.product_url), item]) + ); + if (listings.length === 0) { + setInnerHtml( + caseSuspectListings, + '

    No suspect seller listings have been confirmed yet.

    ' + ); + return; + } + + setInnerHtml( + caseSuspectListings, + listings + .map( + (listing, index) => ` +
    +
    +
    + #${index + 1} ${escapeHtml(listing.candidate_product?.title || listing.product_url)} +

    ${escapeHtml(listing.product_url)}

    +
    + Risk ${Number(listing.counterfeit_risk_score || 0).toFixed(2)} +
    +

    ${escapeHtml(formatReasonText(listing.reason || "No listing rationale returned."))}

    + ${ + officialMatchesByUrl[String(listing.product_url)]?.official_product_url + ? `

    Official product: ${escapeHtml( + officialMatchesByUrl[String(listing.product_url)].official_product_url + )}

    ` + : "" + } +
    + Match ${Number(listing.match_score || 0).toFixed(2)} + Triage ${Number(listing.triage_priority_score || 0).toFixed(2)} + Official Match ${Number(listing.comparison_basis_confidence || 0).toFixed(2)} + ${(listing.suspicious_signals || []) + .map((signal) => `${escapeHtml(humanizeFieldName(signal))}`) + .join("")} +
    +
    + ` + ) + .join("") + ); +} + +function renderCaseEvidence(payload) { + const evidence = payload?.evidence || []; + if (evidence.length === 0) { + setInnerHtml( + caseEvidenceGrid, + '

    No evidence objects are available yet.

    ' + ); + return; + } + + setInnerHtml( + caseEvidenceGrid, + evidence + .map( + (item) => ` +
    +
    +
    + ${escapeHtml(item.title || item.type)} +

    ${escapeHtml(item.note || "")}

    +
    + ${Number(item.confidence || 0).toFixed(2)} +
    + ${item.reference_url ? `

    ${escapeHtml(item.reference_url)}

    ` : ""} +
    + ${item.subject ? `${escapeHtml(item.subject)}` : ""} + ${(item.supporting_signals || []) + .map((signal) => `${escapeHtml(humanizeFieldName(signal))}`) + .join("")} +
    +
    + ` + ) + .join("") + ); +} + +function renderCaseDraft(payload) { + const draft = payload?.action_request_draft; + if (!draft) { + setInnerHtml( + caseDraft, + '

    The action-request draft is still being prepared.

    ' + ); + return; + } + + setInnerHtml( + caseDraft, + ` +
    + ${escapeHtml(draft.case_title || "Seller case draft")} +

    ${escapeHtml(draft.summary || "")}

    +
    +
    + Recommended Action +

    ${escapeHtml(draft.recommended_action || "manual review")}

    +

    ${escapeHtml(draft.suspected_violation_type || "")}

    +
    +
    + Reasoning +

    ${escapeHtml(draft.reasoning || "")}

    +
    +
    + Marketplace Request +

    ${escapeHtml(draft.request_text || "")}

    +
    +
    + Evidence References + ${ + (draft.evidence_references || []).length > 0 + ? `
    ${draft.evidence_references + .map( + (reference) => + `${escapeHtml( + formatHostname(reference) + )}` + ) + .join("")}
    ` + : '

    No evidence references were returned.

    ' + } +
    + ` + ); +} + +function renderCaseActivity(payload) { + const activity = payload?.activity_log || []; + if (activity.length === 0) { + setInnerHtml( + caseActivityLog, + '

    No activity has been recorded yet.

    ' + ); + return; + } + + setInnerHtml( + caseActivityLog, + activity + .slice(-10) + .reverse() + .map( + (item) => ` +
    +
    + ${escapeHtml(item.agent_name || "agent")} + ${escapeHtml(formatReportDate(item.timestamp))} +
    +

    ${escapeHtml(item.message || "")}

    +
    + ` + ) + .join("") + ); +} + +function renderCaseWorkspace(payload) { + latestCasePayload = payload; + selectCase(payload.case_id); + upsertPastCaseFromCasePayload(payload); + if (payload.investigation_id) { + selectInvestigation(payload.investigation_id); + } + setTextContent(caseTitle, payload.seller_name || "Seller Enforcement Case"); + setTextContent( + caseSubtitle, + `Source product: ${payload.source_product?.product_name || payload.source_url || "Unknown source"}` + ); + updateCaseProgress(payload); + renderCaseProfile(payload); + renderCaseSeed(payload); + renderCaseListings(payload); + renderCaseEvidence(payload); + renderCaseDraft(payload); + renderCaseActivity(payload); + if (caseAgentLog) { + renderAgentLog(caseAgentLog, payload.raw_agent_outputs || []); + } + updateCaseGenerateReportButton(payload); +} + +async function fetchCase(caseId) { + try { + const response = await fetch(`/cases/${caseId}`); + if (!response.ok) { + if (response.status === 404) { + clearPersistedCaseId(); + } + throw new Error("Unable to refresh the seller case."); + } + + const payload = await response.json(); + renderCaseWorkspace(payload); + setPhase("case"); + + if (["queued", "running", "delayed"].includes(payload.status)) { + casePollTimer = window.setTimeout(() => fetchCase(caseId), getCasePollIntervalMs()); + } else if (casePollTimer) { + window.clearTimeout(casePollTimer); + casePollTimer = null; + refreshPastCases(); + } else { + refreshPastCases(); + } + } catch (error) { + if (casePollTimer) { + window.clearTimeout(casePollTimer); + casePollTimer = null; + } + setCaseStatus("failed"); + setTextContent( + caseProgressText, + error instanceof Error ? error.message : "The seller case could not be refreshed." + ); + updateCaseGenerateReportButton(null); + } +} + +function loadCase(caseId) { + if (!caseId) { + return; + } + if (casePollTimer) { + window.clearTimeout(casePollTimer); + casePollTimer = null; + } + previousPhaseBeforeCase = currentPhase === "case" ? "prompt" : currentPhase; + resetCaseWorkspace(); + selectCase(caseId); + setPhase("case"); + setCaseStatus("queued"); + setTextContent(caseProgressText, "Loading the saved seller case."); + fetchCase(caseId); +} + +async function createSellerCase(sourceUrl, productUrl) { + if (!currentInvestigationId) { + return; + } + + if (casePollTimer) { + window.clearTimeout(casePollTimer); + casePollTimer = null; + } + + previousPhaseBeforeCase = currentPhase === "case" ? "progress" : currentPhase; + resetCaseWorkspace(); + setPhase("case"); + setCaseStatus("queued"); + setTextContent(caseProgressText, "Creating the seller case and preparing the storefront research agents."); + + const response = await fetch("/cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + investigation_id: currentInvestigationId, + source_url: sourceUrl, + product_url: productUrl, + }), + }); + if (!response.ok) { + throw new Error("Unable to create the seller case."); + } + + const payload = await response.json(); + renderCaseWorkspace(payload); + await fetchCase(payload.case_id); +} + +async function restorePersistedCase() { + const persistedCaseId = getPersistedCaseId(); + if (!persistedCaseId) { + return; + } + + try { + await fetchCase(persistedCaseId); + } catch { + clearPersistedCaseId(); + } +} + +async function handleBuildCaseButtonClick(button) { + button.disabled = true; + button.textContent = "Building case..."; + try { + await createSellerCase(button.dataset.sourceUrl, button.dataset.productUrl); + } catch (error) { + setPhase("progress"); + setTextContent( + progressText, + error instanceof Error ? error.message : "The seller case could not be created." + ); + } finally { + button.textContent = "Build Seller Case"; + configureBuildCaseButton( + button, + { source_url: button.dataset.sourceUrl }, + { + product_url: button.dataset.productUrl, + marketplace: button.dataset.marketplace, + candidate_product: { seller_name: button.dataset.sellerName }, + } + ); + } +} + +async function fetchInvestigation(investigationId) { + try { + const response = await fetch(`/investigation/${investigationId}`); + if (!response.ok) { + if (response.status === 404) { + clearPersistedInvestigationId(); + throw new Error("The saved investigation was not found."); + } + throw new Error("Unable to refresh the investigation state."); + } + + const payload = await response.json(); + latestInvestigationPayload = payload; + lastSubmittedSourceUrl = payload.reports?.[0]?.source_url || lastSubmittedSourceUrl; + setPhase("progress"); + selectInvestigation(payload.investigation_id); + upsertPastRunFromInvestigation(payload); + setStatus(payload.status); + renderProgressTracking(payload); + renderResults(payload); + updateGenerateReportButton(payload); + + if (["queued", "running", "delayed"].includes(payload.status)) { + pollTimer = window.setTimeout( + () => fetchInvestigation(investigationId), + getInvestigationPollIntervalMs() + ); + } else if (pollTimer) { + window.clearTimeout(pollTimer); + refreshPastRuns(); + } + } catch (error) { + if (pollTimer) { + window.clearTimeout(pollTimer); + } + latestInvestigationPayload = null; + resetReportScene(); + setPhase("progress"); + setStatus("failed"); + const stepStates = Object.fromEntries(progressStepDefinitions.map((step) => [step.key, "failed"])); + updateProgressUI({ + overview: "Progress unavailable", + detail: error.message, + percent: 0, + stepStates, + }); + renderTimeline(null); + renderEmptyState("The investigation state could not be refreshed. Try again in a moment."); + updateGenerateReportButton(null); + } +} + +async function restorePersistedInvestigation() { + const persistedInvestigationId = getPersistedInvestigationId(); + if (!persistedInvestigationId) { + setPhase("prompt"); + return; + } + + try { + const response = await fetch(`/investigation/${persistedInvestigationId}`); + if (!response.ok) { + throw new Error("The saved investigation was not found."); + } + + const payload = await response.json(); + if (["queued", "running", "delayed"].includes(payload.status)) { + currentInvestigationId = persistedInvestigationId; + setPhase("progress"); + setStatus("queued"); + const queuedStepStates = Object.fromEntries( + progressStepDefinitions.map((step, index) => [step.key, index === 0 ? "queued" : "pending"]) + ); + updateProgressUI({ + overview: "Restoring previous investigation", + detail: "Reloading the latest saved investigation state.", + percent: 4, + stepStates: queuedStepStates, + }); + renderTimeline(null); + renderEmptyState("Restoring the latest saved investigation state."); + fetchInvestigation(persistedInvestigationId); + return; + } + + clearPersistedInvestigationId(); + upsertPastRunFromInvestigation(payload); + setPhase("prompt"); + } catch { + clearPersistedInvestigationId(); + setPhase("prompt"); + } +} + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + const source_urls = parseLines(sourceUrlsInput.value); + const comparison_sites = parseLines(comparisonSitesInput.value); + + if (source_urls.length === 0) { + setComposerInvalid(true); + setStatus("idle"); + setPhase("prompt"); + updateProgressUI({ + overview: "Official product URL required", + detail: "Add at least one official product page URL to begin.", + percent: 0, + stepStates: Object.fromEntries(progressStepDefinitions.map((step) => [step.key, "pending"])), + }); + renderEmptyState("Add one or more official product page URLs, one per line."); + sourceUrlsInput.focus(); + return; + } + + setComposerInvalid(false); + lastSubmittedSourceUrl = source_urls[0] || ""; + latestInvestigationPayload = null; + resetReportScene(); + resetCaseWorkspace(); + setHistoryMenuOpen(false); + setCaseHistoryMenuOpen(false); + updateGenerateReportButton(null); + + if (pollTimer) { + window.clearTimeout(pollTimer); + } + if (casePollTimer) { + window.clearTimeout(casePollTimer); + casePollTimer = null; + } + + setPhase("progress"); + setStatus("queued"); + const queuedStepStates = Object.fromEntries( + progressStepDefinitions.map((step, index) => [step.key, index === 0 ? "queued" : "pending"]) + ); + updateProgressUI({ + overview: "Submitting investigation request", + detail: "Creating the investigation and preparing live progress updates.", + percent: 4, + stepStates: queuedStepStates, + }); + renderTimeline(null); + renderEmptyState("Starting a live investigation and preparing the first result set."); + setSubmitting(true); + + try { + const response = await fetch("/investigate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_urls, comparison_sites }), + }); + if (!response.ok) { + throw new Error("Unable to start the investigation."); + } + + const payload = await response.json(); + await refreshPastRuns(); + loadInvestigation(payload.investigation_id); + } catch (error) { + setStatus("failed"); + const stepStates = Object.fromEntries(progressStepDefinitions.map((step) => [step.key, "failed"])); + updateProgressUI({ + overview: "Investigation failed to start", + detail: error.message, + percent: 0, + stepStates, + }); + renderTimeline(null); + renderEmptyState("The investigation could not be started. Check the backend and try again."); + } finally { + setSubmitting(false); + } +}); + +sourceUrlsInput.addEventListener("input", () => { + setComposerInvalid(false); + syncPromptHeight(); +}); + +sourceUrlsInput.addEventListener("focus", () => { + setComposerInvalid(false); +}); + +sourceUrlsInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + form.requestSubmit(); + } +}); + +if (timelineRankingList) { + timelineRankingList.addEventListener("click", (event) => { + const toggle = event.target.closest(".ranking-toggle"); + if (!toggle) { + return; + } + + const item = toggle.closest(".ranking-item"); + if (!item) { + return; + } + + const isOpen = item.classList.toggle("is-open"); + toggle.setAttribute("aria-expanded", String(isOpen)); + toggle.setAttribute("aria-label", isOpen ? "Hide reasoning" : "Show reasoning"); + + const detailBody = item.querySelector(".ranking-details-body"); + if (detailBody) { + detailBody.hidden = !isOpen; + } + }); +} + +if (generateReportButton) { + generateReportButton.addEventListener("click", async () => { + if (!latestInvestigationPayload) { + return; + } + + previousPhaseBeforeReport = currentPhase; + reportGenerationInFlight = true; + updateGenerateReportButton(latestInvestigationPayload); + setTextContent(progressText, "Generating the evidence dossier PDF from the latest investigation data."); + + try { + const pdfBlob = buildInvestigationPdf(latestInvestigationPayload); + presentPdfReport(pdfBlob, latestInvestigationPayload); + setTextContent(progressText, "Evidence dossier prepared and embedded below."); + } catch (error) { + setTextContent( + progressText, + error instanceof Error ? error.message : "The report PDF could not be generated." + ); + } finally { + reportGenerationInFlight = false; + updateGenerateReportButton(latestInvestigationPayload); + } + }); +} + +if (caseGenerateReportButton) { + caseGenerateReportButton.addEventListener("click", async () => { + if (!latestCasePayload) { + return; + } + + previousPhaseBeforeReport = currentPhase; + caseReportGenerationInFlight = true; + updateCaseGenerateReportButton(latestCasePayload); + setTextContent(caseProgressText, "Generating the seller enforcement dossier from the latest case data."); + + try { + const pdfBlob = buildSellerCasePdf(latestCasePayload); + presentSellerCasePdfReport(pdfBlob, latestCasePayload); + setTextContent(caseProgressText, "Seller enforcement dossier prepared and embedded below."); + } catch (error) { + setTextContent( + caseProgressText, + error instanceof Error ? error.message : "The seller case PDF could not be generated." + ); + } finally { + caseReportGenerationInFlight = false; + updateCaseGenerateReportButton(latestCasePayload); + } + }); +} + +if (reportBackButton) { + reportBackButton.addEventListener("click", () => { + setPhase(previousPhaseBeforeReport || "progress"); + }); +} + +if (reportOpenButton) { + reportOpenButton.addEventListener("click", () => { + if (!currentReportPdfUrl) { + return; + } + window.open(currentReportPdfUrl, "_blank", "noopener"); + }); +} + +if (newInvestigationButton) { + newInvestigationButton.addEventListener("click", () => { + startNewInvestigation(); + }); +} + +if (historyButton) { + historyButton.addEventListener("click", () => { + const isOpen = historyButton.getAttribute("aria-expanded") === "true"; + setCaseHistoryMenuOpen(false); + setHistoryMenuOpen(!isOpen); + }); +} + +if (caseHistoryButton) { + caseHistoryButton.addEventListener("click", () => { + const isOpen = caseHistoryButton.getAttribute("aria-expanded") === "true"; + setHistoryMenuOpen(false); + setCaseHistoryMenuOpen(!isOpen); + }); +} + +if (caseBackButton) { + caseBackButton.addEventListener("click", () => { + setPhase(previousPhaseBeforeCase || "progress"); + }); +} + +if (pastRunsNode) { + pastRunsNode.addEventListener("click", (event) => { + const button = event.target.closest(".past-run-item"); + if (!button) { + return; + } + setHistoryMenuOpen(false); + loadInvestigation(button.dataset.investigationId); + }); +} + +if (pastCasesNode) { + pastCasesNode.addEventListener("click", (event) => { + const button = event.target.closest(".past-run-item"); + if (!button) { + return; + } + setCaseHistoryMenuOpen(false); + loadCase(button.dataset.caseId); + }); +} + +document.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + if ( + historyButton && + historyDropdown && + !historyButton.contains(target) && + !historyDropdown.contains(target) + ) { + setHistoryMenuOpen(false); + } + + if ( + caseHistoryButton && + caseHistoryDropdown && + !caseHistoryButton.contains(target) && + !caseHistoryDropdown.contains(target) + ) { + setCaseHistoryMenuOpen(false); + } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + setHistoryMenuOpen(false); + setCaseHistoryMenuOpen(false); + } +}); + +if (resultsNode) { + resultsNode.addEventListener("click", async (event) => { + const button = event.target.closest("[data-build-case]"); + if (!button) { + return; + } + + await handleBuildCaseButtonClick(button); + }); +} + +if (timelineRankingList) { + timelineRankingList.addEventListener("click", async (event) => { + const button = event.target.closest("[data-build-case]"); + if (!button) { + return; + } + + await handleBuildCaseButtonClick(button); + }); +} + +setStatus("idle"); +setCaseStatus("idle"); +resetProgressTracking(); +renderEmptyState("Add official product page URLs to compare them against live marketplace listings."); +syncPromptHeight(); +updateGenerateReportButton(null); +resetReportScene(); +resetCaseWorkspace(); + +currentInvestigationId = getPersistedInvestigationId(); +refreshPastRuns(); +refreshPastCases(); +restorePersistedInvestigation().finally(() => { + restorePersistedCase(); +}); + +window.addEventListener("beforeunload", () => { + revokeCurrentReportPdfUrl(); +}); + +fetch("/config") + .then((response) => response.json()) + .then((config) => { + appConfig = config; + const stores = (config.ecommerce_store_urls || []).join(", "); + const lines = []; + if (config.brand_landing_page_url) { + lines.push(`Brand home: ${config.brand_landing_page_url}`); + } + if (stores) { + lines.push(`Default marketplace targets: ${stores}`); + if (comparisonSitesInput && !comparisonSitesInput.value.trim()) { + comparisonSitesInput.value = (config.ecommerce_store_urls || []).join("\n"); + } + } + if (configNote) { + configNote.textContent = + lines.join(" • ") || + "Environment defaults are not loaded yet. You can still enter source pages and marketplace targets manually."; + } + }) + .catch(() => { + appConfig = null; + if (configNote) { + configNote.textContent = + "Environment defaults could not be loaded. Manual inputs still work."; + } + }); diff --git a/TinyDetective/frontend/favicon.svg b/TinyDetective/frontend/favicon.svg new file mode 100644 index 000000000..99e4e1cad --- /dev/null +++ b/TinyDetective/frontend/favicon.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/TinyDetective/frontend/index.html b/TinyDetective/frontend/index.html new file mode 100644 index 000000000..7f3057875 --- /dev/null +++ b/TinyDetective/frontend/index.html @@ -0,0 +1,519 @@ + + + + + + TinyDetective + + + + + + + +
    +
    +
    +
    +
    +
    + +
    +

    TinyDetective

    +
    +
    +

    Counterfeit Research Console

    +
    + +
    + +
    + + + +
    + + +
    + +
    +
    + + Shopee + + + Lazada + + + + Amazon + + + eBay + + + + + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +

    Agent Progress

    +
    +
    + +
    Idle
    +
    +
    + +
    +
    +
    +
    +

    + No investigation running yet. +

    +
    + +
    +
    +
    + 01 +
    +

    Step 1

    + Source Page +
    +
    +
    + 02 +
    +

    Step 2

    + Live Search +
    +
    +
    + 03 +
    +

    Step 3

    + Candidate Intake +
    +
    +
    + 04 +
    +

    Step 4

    + Reasoning Graph +
    +
    +
    + 05 +
    +

    Step 5

    + Ranking Ladder +
    +
    +
    + +
    +
    +
    +
    + 01 +
    +
    +
    +
    +

    Step 1

    +

    Source Page

    +
    + Pending +
    +

    + Waiting for the official product page. +

    +
    +
    +
    + +

    No source URL selected yet.

    + +
    + +
    +
    +
    +
    +
    + +
    +
    + 02 +
    +
    +
    +
    +

    Step 2

    +

    Live Search Behavior

    +
    + Pending +
    +

    + Search queries will appear here as TinyFish fans out across marketplaces. +

    +
    +
    +
    + +
    +
    + 03 +
    +
    +
    +
    +

    Step 3

    +

    Candidate Intake

    +
    + Pending +
    +

    + Candidate listings will stream in, then be triaged before deep extraction starts. +

    +
    +
    +
    + +
    +
    + 04 +
    +
    +
    +
    +

    Step 4

    +

    Reasoning Graph

    +
    + Pending +
    +

    + Comparison signals will assemble here once candidate pages are inspected. +

    +
    +
    +
    +
    +
    +
    + +
    +
    + 05 +
    +
    +
    +
    +

    Step 5

    +

    Ranking Ladder

    +
    + Pending +
    +

    + Matches will settle into rank as scores firm up. +

    +
      +
      + +
      +
      +
      +
      +
      +
      + +

      + Waiting for an investigation to start. +

      +
      + +
      +
      + +
      +
      +
      +
      +
      + +

      Evidence Dossier

      +
      +
      + + +
      +
      + +
      +

      + Generate a report from step 5 to review it here. +

      +

      + The embedded PDF will be prepared from the captured investigation data. +

      +
      + +
      + +
      +
      +
      +
      + +
      +
      +
      +
      +
      + +

      Seller Enforcement Case

      +

      + Select a suspicious seller from the investigation results to build a case. +

      +
      +
      + + +
      +
      + +
      +
      Idle
      +
      +

      + No seller case has been started yet. +

      +
      +
      +
      +
      +
      + +
      +
      +
      +

      Storefront

      +

      Seller Profile

      +
      +
      +
      + +
      +
      +

      Seed Listing

      +

      Selected Listing

      +
      +
      +
      + +
      +
      +

      Inventory

      +

      Suspicious Seller Listings

      +
      +
      +
      + +
      +
      +

      Evidence

      +

      Case Evidence

      +
      +
      +
      + +
      +
      +

      Action Draft

      +

      Marketplace Request

      +
      +
      +
      + +
      +
      +

      Activity

      +

      Agent Activity

      +
      +
      +
      + Raw task log +
      +
      +
      +
      +
      +
      +
      +
      + + + + + + + + + diff --git a/TinyDetective/frontend/logos/amazon.png b/TinyDetective/frontend/logos/amazon.png new file mode 100644 index 0000000000000000000000000000000000000000..dc0e8707bc6cb412920237e2f78de7d122157b18 GIT binary patch literal 2054 zcmV+h2>JJkP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NJa7jc#RA>dwT3c*YMHJnYmP9Cz zf+A8{qY^}DNwE@IK3Xi)D3U-;Awt3jen3Jb8cg^j5N(MhJZj|e#p99~F_HFSgBts! zCZysExhLcw5)u-sJbPu}nVipZepb^d1~yG4ksu$eK-7-h^7(1er}%FD~6+?*WB&dv_f zh1v!r=CS`)V(=q4pXVGX?b*9m%bGJsOH4}AMnp5})(oB4z&?EZc%c52MyGT}EGSll zzN@XRtp;!brh*p0s?1t1UcOv=@Zf>(J{=4TFak#%6rLi0PfbfRqU;B(6}+@rR9L7D z3=DYo=b_vO0(U(OY^tuVE^TUhx&ht_%+L3T#6;6{`)1D`TR%PpH=g5pG(lY*vFc@I zWz>E9b}TbbJr*~XcVvkZeYJhNr@eM%>z2*8A3>rYBeD5p z{dz(k92_K`7P`3c%o)9Wx3Eym9LrfVXBu*AP)8Je9Zzh+{-rt^-nv&aE6f zcC0>y03Lw#Qq%P7hLw<;4<~sb0#1%ZhC2}-Oe7<7lJ7hItPEhp}#Yu{5gj^GruUMhg)YO=6nLKK?HhMd5ixO>uq`0P# zev#v8V~rT1i5&%}ap>@2Dkvy00;E=~?Vxj#scmA_eNQ1Q+MT>$0ak`zoC}C;rKPUm zPd7H&+KC@2#a}$7ZvwSE_W@M0+6e$4`m0y3R>iJ$0W8E(ip;ft)#30%B*(TI{$w0Hq}_JtRVCy{&;=+*_<3gTVP)5b~Zn?P0;o{l2B4Tjq~Ab^8Lg$oxj{traW<&`%F;-leyKY^eXU4dmRfOn~BS##cNd%C3(JSUl8ZLoq0vWMI>Gm9> zwsETmA^>N+J!&8VfbvcX;D|PEmL)t=EiEl>{c5$fwYnXAxTZVjG!40j^I)f=nRyrL z=a+`QD@3mp#h!al%A&W4sC7S4$H7PsvhuN5-ftlq_lBuw^CE`PnGg9_3lsP!{!`K9 zImkyJ2e4)?(ZBbJrmi8HT}JfU#^IIzaKP@>HZC1ins#g=Kf3emI=j(z=xpttq@b=y z)4pRoL*})FF3lqVeyNLy7XD6@G?D1WE~3g*qH~`U4Lzk1dBjn^@->R|MsV=hN$x=qCYuk zNSOQcjuDNYMfA%IqSrZyb9d+}<fzDu2$A;iZsf#ASOryTqLd~Mx*Tz(bX?_ml!5u*n@~90ftf%>tUjc zL@ol9qd5SxOYK`Y)}B4`;&q|!ib?S1OW7jzEOjEhM_>JtqZuUvF93n0fD?SWOg_Xe zV$CC#M>OLzJ8;pvm$m#7Vi3q=JN&yV#@>IDh1VV$CKClHcn$F46$HmK=ro=hsqfhi zCNYaq;>tRo30#+W;Yv}!!sDEiV>EVF_wgw?)Irpb+a$Xk`kQF*Mr2KljIaLV57nfA2aoH k`|F$$ZTxqelU9HK0WObDCBrHK*8l(j07*qoM6N<$g0Il5g8%>k literal 0 HcmV?d00001 diff --git a/TinyDetective/frontend/logos/amazon.svg b/TinyDetective/frontend/logos/amazon.svg new file mode 100644 index 000000000..34ae8eec5 --- /dev/null +++ b/TinyDetective/frontend/logos/amazon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/TinyDetective/frontend/logos/ebay.svg b/TinyDetective/frontend/logos/ebay.svg new file mode 100644 index 000000000..74d88a846 --- /dev/null +++ b/TinyDetective/frontend/logos/ebay.svg @@ -0,0 +1 @@ +eBay \ No newline at end of file diff --git a/TinyDetective/frontend/logos/facebook-marketplace.svg b/TinyDetective/frontend/logos/facebook-marketplace.svg new file mode 100644 index 000000000..ec55fb45b --- /dev/null +++ b/TinyDetective/frontend/logos/facebook-marketplace.svg @@ -0,0 +1,6 @@ + + + diff --git a/TinyDetective/frontend/logos/facebook.svg b/TinyDetective/frontend/logos/facebook.svg new file mode 100644 index 000000000..f66767e46 --- /dev/null +++ b/TinyDetective/frontend/logos/facebook.svg @@ -0,0 +1 @@ +Facebook \ No newline at end of file diff --git a/TinyDetective/frontend/logos/lazada.png b/TinyDetective/frontend/logos/lazada.png new file mode 100644 index 0000000000000000000000000000000000000000..9245062a87b13a2387db0f5429ea79b07b027abd GIT binary patch literal 19402 zcmV)TK(W7xP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91fS>~a1ONa40RR91fB*mh07#AmcK`rD07*naRCoc!y?M~)*Hzzn-=({y zmZjEeEjHe5d0!C5Bm|obNs%3g1SUlw1d^JnWb#LnNewkMHUFe)mP&zj>Xy1&y}e7mpYJ*6-uru=_ic$`$?f;~-FwgZp5@+i z?)@##(i^+4`Cs?j-hNw@jnNCvL`c?tCq=`!jcgZB^?n;2%XHdCD;@iii+0q=KtnXz zs=*|k_MY2G@BNSq-m<-i{Dj-nIH2{H^v#$--yE2f-`iNBnZDU}Rqu#Lz1Ie2SG2NS zdcl~c8a{p3o14~QJ!-(pPhm^Q20&m?Of5JupGvXcBMR zZC6vlOY~6|oi*|zDGZ(PGUC!nYOJ-3wo+Rb8Wxf_(|TLZt6hT`cx3X06V>i=8nNY) zXB(jleM2WYgr#gSH>ES_MN4_>#Ajp#@uBz8TR{jt1j4ppNFdZ_y}92uCA+29zS?`L zH+?U-*GSrcL5h3j0k5CzWe54;P(%Gi%5;6yLBtfB?F>+vN z%eFBJiwy4<7F;d$t|ReQD55YM!r*8WO@@12AZ#VK9ASKjkO(w8-R;sqUG8H?ZGl6E zV@X*kf&s>ekSiG{$xKhZ=NOM;2;6r_on&cu0zNDcIQ;bH_O z^cFp80vW+&xPcq84w7_L;FSu-6HBA4+#O;oTo&otlL7blB!e-EYPYGg4`_o^wj99D zhHJWNlLX@XG~j55hE zVjD>tq1Yf(%*JMwz%isK)rvmc!C~mpr{=|^6O8Jw@@F~;h4zv|ulZ`QHScTu!IMVq z&1}yEDR8k1Y}%sOA~IC$uNF3O{s?G{Ms)_=_C-hUj%et>W+Vdi7S{~AMnGiDXW-Bn zI#tUF?EnJtS}74nYY0wZu$Ds_auZHOuPozr0AI`5!G%{_MN z)eyoD{852v-`wlXnOu{e>WnF!NI|dHj15{??R;UG?9vaN4VVy^u2aFLGGK3CbzK_> z1cEVEA~>$1hrE=ikdR#3d0T)1UrLQ3=yJ}<0iC3i9Zmz@5zNymK{mLM zGR^n621po{VXHH=K?x9O+bLl>6!ps}UD_1Sa#SfOV#y4Ou|AaE85LZn)pm3o zt(CSIX|h`ZTQ4+Ko0=XI$uVkCawu`ZxWW^PAW%5&%b0fthXIWQgYeQ;?fDuO`w2tB zoD#P5+Tend;Cr>pUS@)&%qRHp^>XlI7ud}9u6VvHk>)ux#(ZBF z$O3`(IAyyMg42ZBTp;q=5ua&ihwy1aeqPq;fZNGk)JhF}H`QM50Lch$fELH%GumDz zB2DwM;YtTaOTn_*^i1lBkc8xm3tB};gK3M_1+#BHI)RGmpunSq9a;oGRxG`th818@ z(_%%SCS)aJg9@sam?t%5xnuFx3sjg|_4J%H!V@@j$BK_Id$?Mv_+o@?IWYvpEH*Yb zxr@M;!1N@5uSDQ>kL8Y^enFQY0mot`2Ksk&A}K#FgC`Lt&v0;Y4SRkYf zPDuoA@aFz%8yyT;g$jKPLbM7AgR4~wD9`}2lw4N;J>`>EkkA#TfD?$|h2GE_0q-kJ z2w#k8!k~d4uhGZ%9tdGr`536cp&LD%r{NVnLockCwNM5&Z93ghXtA`tkQUu8!k2Fr zlOOB^3TzKNJA$0znL#8}>;ybZxXd_2+!fw_3#lCdjWO+Ns4#++;?!NC(Ul`e_Yx^S zRH)B1I@a)*DT)V?k}&Z!K!GvKAcK)YlZ&ymf|^8Cq0javHXd42!iUzO0hYDvP)TSS z{(20-H%$sSFJp&)jLADf>M(|Y`(Nw?xKjZq9F)LO7sX_s`@NXe9m$fTlkI&@_UKmS zokZ&50Z}BF^=giMN)XR?3bMgaY)d>&6Ff;I`Md)-ZrDe|}s~R5l7BjG+!wN<IV!Odat2ASn0$x^Tn>1wSd31oV+b2I7XU8$e;%I`gyu zna9SlTZ9jh2%>3#$md01nLzwXWx}cHo<)*gY`Y;n@J!QzSt@>+@hn3>fNxR+82(tt z3Zjz%KZ^=DdubyA4=r`1ak~Kqyy|R9r*x@PuJu~BD=DB=AenOdMXP?TaO{*m?*{6Z zEzaum$pJbA9iiW;Y+9$MFcmmTTzqPT&L;VfO#&{CgDzzi{0O}Iatb->{ax(<%ze&{ zxYiqo^eU^tFa~OZr{(}0~ zxzt>vaE;`uOPps@rFan-F!KvtbAQ7Re?;L*O(l|piLdm5R!P8aLC7lfC=*xC7p-)F zh}%Rwi5I*WF+NC>lEH}}^8}hLpYi*)(6@`aJ5Vg>=P3XcSxVEv-JmAMtI!l#T9Jzx zV@gyOfe)Yq!<5mTg0l?1n2dx{I*g}pV({IjsR6m2GJ-HgffOz6n5#pmEE%xq&_N=` z&_`IM)74<>gn>HvzOv6Y{d!r>p_NPzWdc=E;_MGM6G9hdd+J0g6rVI8+jHS#P#vWI8V5 zl5!=43P;clb*8#E1txpwgFu#p{}_C=MYfD4OElv=6T-`e#ZVwxB}w#5w0fHfb??(QTjf`L<9_#vOxdO-k+n%oMLt%9ts0Ct))wFhvnyR%LG{LI#-0vqa~s zmw^Qxu%c;*Op1dmuGJktOWp7d4GZc9!EBtB@lb_+cU`M#)pi3qSQYBxRUfLSJ`63e z;CT_y2=`K#c~ZEiq!?Q~!Q|?pu;ST~pp`Caydmfy851z%DDd4EoeMzUt}NFBDhamMB7l?w3vkALyGB$)_}&;hW%6lHejA`R8x@e7knrd0~~yI17DZX2CRaL zUhE%%V{M}+)_GpT%Z@aoPyFC1Rn(6MV-a{UJw(z0B%V8o)b;9?q&YflZ0O{{ts!>@ z+%o1)5Ica=25-~oD61WZLsWW1RJP`M1ay~nzmkvcWpip>8fCN zn?Vm5CL#j~K|1He!d5?3IEjT#7ivpK8kXHjSHir2=~tL0d}Rw(^y7|#a1g@er#|E= zDbPt_1v_cPfJb7~?!Xi<5d@!A4Byj*&;A#lfQ!pXY~oc|4qxO~zMsH#>87&F2_@0R z4iLt|Ajc?SfcD@g%N5l1fP1D&V5kTsYyHq+1C<16qJk5~g>&$!1<0ivy9Ir_k^!Zo z(gV(khU zGjK5&d&Vd|`{E?ofrE)2k1iRB4fSO5j6r_rT0dokt_n2jfn$`=x+TTdtxn;p4tK&{ z?H2F~k9=rZsRi(bo%jxLfn>V^PD0eKfJ=QBNIJB1Rgdv=ix-VjJTB~Ty84)@cGcib zS4PDt0M2Sj+Ci)t+W9m*3$;GfH*G=}80)=EpdL8U9Ml9&kgn6T3741(I5j2CfokBJ zNiwd#SncvvxCk!vz&H`Wq!{!Znu#$&ZS2O!j?h=Wl$CAMq=%pqWQte6>e}inTkIP2 z32%8GD(T!ode#d)>;_B`uNaSAiy`V**ipwtVT>tG3+$xvb}$+bDCN`vER)vw*1m$) z1mi>uGyx@u^OPQV+In?5?INh2RyvbkD?j286JG)M#B@6)o3>EPDsk{-%#@|iKO7*%{8ATP>=O7`sTC;*CUhVdcLHkZX`shrJRGd@ zFO(dZ0BN5w9as+I2VF6r1zwd)V#7WO_^7CoeJ;*6Pvj*v068OU?NQ3 zH@t&H&?;FfkOJZwt2|xo!3~yTC0a-^=}JHXZfh2$f!oM$a!7){EY44CjaUvvju!STJm6gVN$op`WAg2aQN2xi_> zfK>(nt>A|ky+~B-2A^Syj!~Rc0Gt3Y*NTbdc5po>OOhE&d3pir^d$9u88f|@s&Q&A zo*~g$R*s;mga@oTRYRTH$Rk?Y#iCuwBDO_z0B4M*&J!eACroICZx>M;+0^-Q3c(iB z9)-7J-BHvDJZ2)`1dRmY7A-dN;v;FCYKDz(^r^9p$_TV}1JV4!9#*xO5pZ$kXY@nX zL$VI>z@b-t4$Ip>h!k_9S;tQ5jo_f{MHS=@!p`(+Yh+%5H(t{=Zo9VaKXtb4KmB~n zBC}AlQTSe~VLrBsh<8j4lMXxSs+dTe^6t`u8P=>;PfcZTVYC0ETOY;LgG3nnL1Y)TzN~Lome{!Do#08JX(U z7x>hV4fHKe0rt=ww+`wKhFYbh1~)TT)Ff6^0(5AR<9v4cs_nM<`(E9S{k8YC%{SlP z_I~q;cHsx_Zo9wsMLy(~X)jz(O4X=lfYqn=Eg1thS^6tuD-qyi@Jn{EQke--kg`m$ zM?oK+{+tjf(Brym-*IcZ{kz`Tj=%nPHN7=NMU(ry2<-Y>+JEa^>FMLn=S z5MzgQSSbT5UOL?&^Z^jTH@viMf7jQxqu+j4+c+q(57?S?=0 zhW64w{g$?^9e{^2g0|e1=@C7dD3|l3uB^C#Q?W369?r*xLZN2~P?dV_C zs{g7RyX=Gn*mgeqSUdj%ztnbq`M$P)LED7};b~@)Jh-T_9ewy}_X$E{PQnVkpeip{Ix4b%>Gb*b_)mSIJ^6DFw2LRtYbV1G ztxyLflCgqQ`or(=-^o$FcOP>sYyG0DMkW)k*RqNIMFOO&;qW2HD`5st%B$G1>O_r0 zE}EeIW7_V&{x$8$U;D(&P(yBT3_S@UdfBD_*_&Z;w z%V=H8>%Ddc>lpVyDd-4|^h`WVKf*vTX=n66dqW5#W z=>!|q!Rtv-=~W}e{C=vOrP=Z#T3T3UYP8a!U{R2@2Y*xE85oo z_`Y`J58l}}j;`N?tVq4+?(SpHw&#E7o_6skKHc`8)NNx2O1sw+VggJBn)9sNT z|6F_aGf(=WksJK(uY)L91rN2znN9-G7L9Qlli2FJ4qK(ecDV?Q1c;jhLD2HTr3N9R z2Nh>yF_;DZUU5U){+{30j(pd9+J<(3-<6`><moF00Six0|S_ggjz8IU6VBkvDP(t} zDV{+5aS?c>1W14J?}}yM@F)t-ZM^l4w*A-tVB7xYx3mpi?f;$@IkofPGws|DeXw2l z>Cd%Yo+L6!IyEr*2y{%6Crwn-B*%C*Z@H!&|6^}z*MH|-dZNcgo_0wX6+G4Mi&V5Z zu+Yp;87eC-ddkf@dfO`Z+jCzy*&h8j_qH?lp7e$@0xAE&I2llsNPxTsK&d@N>6Ob8HUf_n6S48$*R^AR|J&N;D|BD{->~do-ffpY^+-FX-QePf z9&CFj&&91;pOlOXK59j5-gLYjea|b~iSPQFcI?iVn#QV+0P0ACNy=umsXce_lNo&z z#7hBJb9>TffiA9C>30Wp%lpv3{!BZ0_k)?VtSniW{Eq~{>clf&x~S`=ZuO}dZ(o+- zCIG#92Z{BvWFrv8bPm@ZYe)Xdd*gvlG5;IY8+xvAOc$OlZ8`)H^@o?h z9L#3|_Vuu%J$XitN-wkxWpcY(iQ#kb!7{(&)N;_Z4*cn&i)8Q$hhBlg-@G7;*|ku( zG&NtWj}8pr7m_tUL8>-1Kbt%q&A>atQi47L-{0KAUjj_v#gFz#h)M!;d0$!ptOzNP z+R<4i0JIldu7vHpoP~W>+!J~yOc}j#y>0;+9Ot0li{g={p>m8G`dqs+(`m=clQv_S ze6betWoHEkY=KkPD|l76rei(uB^)w@a9;gznZ}U>bofKL(?T^pwUrShLnK1bbVXdJ z090MMpz`p7F^a9bTEoJ6UWstwIh_)Y#*V;)Ao=vWQgEn(T;BCQyyV+>`t9~ihXC+^ zV)EcIjL3mOvsB{Dhfs-iR&}3NPjjpu^O?X!`8v z9E7C1f?hak^@AR5cRD7qg)5-@h$bH){!HhPZPgokjO@T~k2k=U!3TsyMJW|QQ`40W z*y4`CHr(%Gf?+ck=){B0=K=aPK)6<>kii)JgUi7zSwL>Bh`w5QjHjM;WB?+!H5rapzrIlWH0V+&Vx3AkUvQ6tI)9=D~u{2@W`R>WMpGaZ%$ z&uZF-gAcXpiD86_nNO~W-c8g+%w=jrBB^2rK!ze}VT16($G}_N3xltO&~tmgiK8pv z4ZYE_QFjbVkUb?wJPMNnwoRQn4!xVem7J&=P~VP=-e*MM7(mpWzT#+MP~8R6&I)v` zi%vyilfWO%6<_mhusVx=X~5`U(BgeTF5Dp3+N{qrLg0K4&*yyrDs>Iu4*hI|c6NZ` zP-A65n3>T&n2q4zAB-u)3)Ff;Wd8}>Iw}D+Zal8{{7<$s-}jR~Rp8s^t8Q%9egC(% zP4RwPWpdI9Qp%ubq8L(1ku<}MNddLHd|^t5AY;XkIZ8>m(FDK79t2vf)`%$vA!vn* zmQB0t6>qkw!OJ6tf;#Hl0jxgRV$ucaqK^BWP>f!W?Y@(K`wf}fe_n5b=rQh|o+Iqv z`-HC-@N16`h0dR0oUP$z1pzRB)nBa-;-jl@t8My{>C*%5pps<2(ZW`L=mRh`9a0?0 z;CV|Y*y_eKc-mnh@yMGPywIMffY32HR0aZtXm8Dpv^|rueK7t2 z;Nc`Dfd?~}6>!?zA~C4n%?C--fTre13K30@nT`(aAV$r3o{L$oV-B5$!C+`rP!kt$ zNr9Jqg{@x%Iy}%NKx))!$xsKMBnW<8AB4uhcAN5jc?t-W6h_8HSL&r~EhiFNmFK`6 zj8N>3TfgN4gP2vUsOnGbX2l4s1mm}~`WN!EF51~wHEcw?a)BA=>OSsd(qYLl*@#!E z07L76fQfv8+@CvYql-fddizg-iVaX^2OIUy(sG9HZL3JI_Mca6W99WeJ<6Y-TOoGiY?=3KhV8WO7A{*21p zj>KJo0bzNu`f*S@^1eQw?>h=kE4-<4&9Qh-px*eAJ&(Q{Qe)D&bVoYT!6_+ywfn-? zDqr9a8{14rAaz-mcB;>&{J}s1v+Z9_VH==mrS@+r2?sTTpK+y+O=R`AehM%XO)9mw zL~k7JKqy~0Y#HPR^u@I7`aBUO?>e?GW9HdVn}F`?{e3>E9b0Apoyn;-vR7jK&MHZq%2sTF6&?ok5?yRiH=@ZH%}t0p~jtl z>QN7wA*W83{R_HvyhI}C&8C;k50ds3_^-6nfA2rH%b(OIhJ|CtEB`Kk{;78U7w*#s zKwi21H!gD|Sg?9OK`VImYqh(mWmM%!) zYYspYx_R?8?Zh|V*|u*a0R))G*4tuSX$}%cu$%yfYYY*vuCq3P@IykjrvphaEoA$M z4m@AH8HAxe+eU;EG~#(Wg5t=jA{bzMi44L4M~N=W6$d`_Gs!X0YZ^iydhko{@$(im z*9N@v`24Sav7P?kex~g`{A^s6^Ho~E+@ro^t;a`Y|)ZzHH-G#301~$@caZnW*}|JCsRFc(V`=P;D=A@;lmVs zt1J{U(xXSgw5(wQul5nmVpi}mw-9oo%>FSw^b$|8i+wnhqr_SpN3OHR1?kH%d z{@I7x`JekueMC&}OLSem;UK>B`@YFvYO&q&w|o`!jO3sGhaVJ2ry2Rh_XckKEBe;c zEiZ{(XQ&J5lBKu8tBXj6S1e|^&$OI0or6I-xb6h?A*jes$<@Um!u5za-wYC^WJ6O= zDr=UKR2POq_1dv~m|R`MEV3S(b6K4f_=zd|5ujp-+I3_@=jhA&zTm~Xzo;>YhM+#0 ze&TPuR~LM5R%D=wsMQ=R`^I+j|MG|0Nny|ZhkLD$$tim#NMw_13ykTn7rIKYbNYff=ie|5X+-LH%fg7O_gZUadI2CZuXc7*Hx{9XP;a4j5L zz^AeS76Hq-WBnKIKiN+HUHx`}KFe>g671SP^VW9a&*%aY*^)<`vKVKOOfF!m9voy+ zm25L2w(d|(8(#KPmJg2b8dz}o5_aY*0Ybcj=)Sb0Yw*s;IFO)b`*R=#>$Vsf?uIufDi zSr2zoEkP1bJr}$+Cc1-`{drR6HtZZ}>5AG4Q+U!*6m0AKfBEs}JlU4f*1e@$I**XM z08RmQA%{BJlH(J&+i%wQ0PIK1N2%>YC?8etox9Xd{)1m_=YRcS>)?~b$KG*!d+FcQ z$Km;u)0l+CpZ=cZ^tb8{`6q>PR?RBxNrL$>9edJ1dZa z?x%GGpOOLZH1)lBd6cUMNg*dA=*AW=h-FLt{hi%*`o}-j&gvNge5UHit8Z+#{7>&~ z+xktT{@DeIbvfD;^krEKele)a-qh}*1lt`n$T*Wy%oxBVo`kGyLc$~tB`yNdCPGXu z4ROi=19eD)mf~P~@0o8>;i&7?y-U0?)t);kldMuuI}v@7!M@q&bYk+A4_I#LlgC_+ zf#*8_yE^Z0-yu2R!bb2zk42RnoCQzP9v-dox z+r4|@sUnEuN7_yQ!+Y9Ge*de+_X@zzawQKLopYJH$jovxFJ?VgZp<$1zTM}HN1|w0 z;05ywd+Zwd@@c_*P7yN5fc>IfeQED)KlaX-`JoTOJOeoQpYCs$zWhvn7H`OVCqRcL>deo4!4HJ^MsST` zcp*o0?QlU4m!A55{Sd`d@kLV-=elpz761F+8slSZ_&E6wsY>17SHGso{aFUX3*%iv zWX-MvOA_!3Xwl3s47J_(dXwl0-R@m}NT&%B zO@A->L$A{>h3JZ(3rn67f@gx~k^}rL1%DxLm!z6mD3{U(t0U<#Akkw8)<9Cs1$5!u z7C#;Z3t4`%>QUlS8f>U&Sr^6@lNLf<+z?wT{-Wd2bTvR~@)vCl9Xc;f{EnX4pZr_; zj37bql;jkU(fUgFZGZ1u+L7C?YuEp!_q3-T_?fo*EYAZr+XW@VV}J9f+O>b;P3x-k#J|@Nppc!Gw+v4n7R^{Sb_u3#bzz>Cui=PzeH_ zL>m3%BWro-gRhmj_w0ps;hryh0-fOu6r=ss8-WqaWuA~EIq7AbxbiDpelZxoG^A{^ zd(*~2{NPfNMl;%pmzak(fdmf z{wGrgU|Qlx2>N*nKo^uWjNWXj6?Uo*55v_jzSRY^>HYJw|LoU&uDx16t+L6te*8(^ zSxAC&w``-2l02TPZ|faA9=sF{Hp3Zx4MqO*;|~7PMF9Na%oE@Gc5V4DYo~tflkMWC z9@pbw?H>4|?Ri@tEx+nrceES7<6UiA@7XSDk{oCqMdbw&M4+ zW4b6~(iaa&fLpl_C7iTmYQUU>Cr^xH3=1hHPp?hgmiZ!WXbZav`mT1S9eo|G>AMF_ z7lM3HV~d}o+#A>P1p|m&R9|2@o!A^4SFRQ>Fkf zou7C>6^@JdKHg6M^AEQRzxHKq0dZO?lT|y2R^;U%!6^O!h??Mdv1>r@Kso^d``GT%j>> zcs!Th&qnXhAhqX7S5Uoz6KfaCPiToXn?nvEW(&P3m=%;FjYcqioF{JfW0toL5ugdP ztB<&y(Qg_(_ro94N6^mLUqnlUZM({#6$M--!z2T@ur#=Oa9_L3#+P3Ie?|gikmG7H z$1Yk3zKB)d{pt;Sk6_*0m0O7Z8e4J}q!CSd{)T zVobob-fDoz5_XKKX0f6^KxPRDSGDEv3*Tpc`t!Q2dPMKRzpGvQN8T9c;W`bW$|W1U zza-n4RbGuFp`m61P0cIuUVQLZ6s~mp%wufk5fM%Pp1vA?_80Hdt?7sLlXj;JnS*a1 zeHgP|Y7VZIB!Zfk0IJ*wx(juUfa;C=?)*FZTooH{JKFAtJtf85u{Q_PlI3a=nVR7l z1R3Mbgp04M?s{3fLHFxd>-TqkvEi$IR&E7ab<`7;jiBdno2-@6TbPi?2 zn7X=I|3XFx9yRNp_WWlbZ>RpnhuirNKG>Q*n~)zT=&K%GAv*5@L93PDG3*p+hiXl3 zjfbRgeFu>AB2dB4BhrKGd`N``4AzjE`6*o_-+_S4tKH{+{VVOl7oKX@z5gxx6pjAB z0Nzf;Na@Xy(XBxuUc-#`dUpXX;})AcD{_b0TEQ){V#!u$+Q3dv+eZ1 z{)B$BP+xB0?KtM*z!Jglb5R)dYC=lA>+HL&CGVY}022>V%_Z%P&%f#KdIzv>IFwrT z?kBN$GvDCD53|TB3SK{pVNa8DME}{sP5La|HDCYAwx!!AKbud%LxW#~1n2fdn3`6z zR5E7JvID#dJ2aWBbQ}&1zHWc{Rr7QDp~|QK$%opdd-eSSc3m5a-52Y1&JT?l#EicA zX{tPpI+DcOJb_1#Ut{(=&Gv$Bn&y1tb8r6seivZ<6?{6z>M(STz5s8*zLMNz|G?m< z>?K{~J^dq}XlMV!=lxklwq9(69VLKZJY$cG& zFI|(xdjWKl*~i43uhPtf2y-gcomf4{2kJ=KoS+it$s3Po&;0!T?ffSnZ#R6$JM@3> zys>TTHwOLmPWXB_v?sTunN7lDDhRB0T$_ z?rTr~^N+M0eK`dcykAi}>_Wy97Ps48&FFGL3S<=PaC5A7_=6t9w@1A<=<{}d%v;Y6 zrjhkwPPDH4uprKKz?1A^HLvhuN9x)a4Xf1ZW_y4Bvrn{>|LUXd+y@`>lSg#c2**~> zT?=p*g<-+a2=@F(8bZvOVSwPX5E8~mAtQXpJ!&K6TwC-w1&@?v?j z42o9ex^l6&kQ4v1F7lrFN&Po*KdLX2@(pIud}|kj5=afv7{f?h(P|K`tAl|+`MnOh zSr~P>x7JQ^`15Y!z7Au?bR$;(!H}cQ01*aBahr}BsdULRsW++y7zBpWf0=jxkJAJ_LF&z&k_WL3cf-*Q)1z{n8tlz$U-! zr930kO%JFEX&=az4OYrEx--`S4n&8B)6FU?NzoaD=HlBzp_=casE1*U|^d?jp0 zKVNt17w&IQe&7@OVyND)7rQR<;&M&|ITTt*feK*g>DtTbR8IxcTQ$KjaDZZ4FX^yk zY?mL{PI6K^ruDnu)lR(gcKzhLKG)AKF$HdFhSgOin_BfG+u6@N-k$h? zp5N<17N36zd;}PeV?}4cHJ_$o0{55`)1qB8%$F?l=6>S4yy>A|aAQ!{Wm8A-YDmE! zIP+r%km}V*3AMsVb1$gCaaGn@steE=3#kMb=*o4f>{chgf+j$jRn1Ox=95q8zhnKS zcGEY%uHF9U-rBBy*$L5w*54!^c4tEAfit)1&bZBXV5)BD_x7o$hKQSPctWN=P&tBv% zkudc^7kO^mV8N`iD8oonO1uMB8%uG?i?E=jM$okI_sU=hE0Sr!9|{aNaFMdA8L>Ot54|T;ZN%#PoD5xKPSegMcUZG zNiFO7>(~iXm0PX4+E6Q^1+O`5z$()gf-#ojK}_$S!LuYJuw+2>m^ zE`94ylq(&wo1m4xs0)6e<|Ay3RSmD&77gzpXGwQeMa!FJb&Lu37jVD9w5gwCz5eT7 z-d_IQ?`+q+{>H}tys_|awl%k<%I^&x94nQ zFut8D&|<64Nl#kviZ<(krdO9u9)0=6DFYjE>VOygRhs+e6(;IVS7MLbKy)5huqcL% zM{#F~Jb<(WV3Lx7OeSg9MQN-S(U^)V*5U4;nU~#t{YKQTKIr-Me|fk)_Zv^OTi<_Y zyW?B*BT%;-U%O@WOqSx3&%P&F=f_K@F0`j~W&hZZex_Z1Lcc*IJ{NzKejiU1w1tLm zv>8*4lYmKTW$t{+&ESq1umYn@96YU{w_vb#PPv@{h638k9&wj`g9OW+qQ^B>~auiY8 zSyQ>p$uYGH+I=fMA*7=Soz@$n^9sjwb%EIU<@fy1@)R)Xi!4ukr>l|eP6E<9k$J;P zAMSt*rdr|;$bvJ|t$q%|5_xz}f_w?&=5Ksu`>OAFTf64f`h6f2=V~ZHuT+uGKYFG; z@^62mJ@wP~`%9&p+9nws#|rYYEuL(UmPL2lG$ju9Z4q2xu+8`LVD|6uA)ovqe+bRk zn6|)Lne8v14@h2fR(@g8ZRF{23fSd203I?SCWkU%)J1*v)ukW`HQEaa)1U<3CXyCa znw@Ai5=tg&Z!T1nRdE;ejvfv8sT$&*-e7wCXCG*%KlNn0k&lF2=a$lE$a$=v=f!0;m_ENjNCv~YI`EocZpdF2h2!1 z6__QBUP*!&{K78r0a?;4-<<-q6Xsi8Md9Bh= zUYx2+^1!~-7r!ilTYpB8(#Fropf4!ZF;;v}PHU$=`D8nD-> zlz{iD7F#=Hv#c%+7!V+NEj*-p67GhFqsZ znq;CG$Bfw!DyW5XO-t{N?(28HsI4BpM0snp>nDgZ5JSqgXd@Ye#src;l>{gN<6z*9 z_1FR(8Y&nA7`wM${|akQUMOWTwhh(d|o40DTKaCqy!1Qx7;? z3+aa)5{#@ah1gtpV<*y#Iq+-yEN3+>;q2-$u7*~r*~j<_OZ_;v4tm)x3L?--)O>a3 zzS{~qHGJbWe%A>MnwS*PJP&Gkv4f2a3B~F=ZW3Co8Dm7We5k4;*6e{~Q%Tq_z#(;^ zXBnV1mUMp*U?LO;8og{^+SKpsZtW`YdwzJassClt3E%-mGSH7CkescD3pQlMsZRvo z+l$7~qKrv)A99Fyem1g7W{EGEF(~nB%Z0rPon2a(TGiG2LK(R9l`+SvpS3U-f-A7G z(%rEW5y$9z2^gb(j@ffkt6#L`lS1KCDv1`WA4afgsdxfZLyVbTor7*PZLjf(sibuZ z(2s>GWeGgHz@~mz#Xl>?``H4`rv(P(MCki$D3P8;;akFUS?PvvGhopcdQD&eB1GyJ zM)hKDCe|t)ZyCcV+17ZxP|bjbQq=^|_~PgHyCa>(p2LWp^1qVsZNcW}?=T z0pY|}-HnKzic>6f8W$dzpRlli0IfJWJ%qQs$Uar1yg?I|n;nWV(d=A*@R=&LjMHs4qNkU?kd5>8dItTTR2V=n%SjYmK zt%T@FE$ku*1VFoF*nX+eW_jiUuJASi#{^5b9rVHn6M35pb)H|57R4A-Qeo$=_EFNA zYN)dcN%r=|ZGE4NekH=D5<$B`)pZ)+?!eawQpSHY5XrzGBlxbRDH0)UhSfSQ zJhJ)j6OAlWvq-LszN*CulT3Isj`mDjD4aVTqy~xL2}5)Ctu#Sol8cCnWOXZe#j+@! zfQ<$!={cbQgg%>~D}g?T?wf?E;2FOv6G?}Tp@;TFmk2tH(V=kLmp0=6w%gT`cHlV$ zI0>{HOtek4`<;S!0G%4VBjjlT$D9nQoHjn=g#9g(COX@F#}M6qG}MC(FKaS_9yGwE zE$GXB@k%M4Fd1NjH(D7EaSByZCP_e8Ck1q2P*CF3Xf!68?)R)nY{!Hj3;!%$N-bSN z>8rYG;eDO&10~~Wy7z&qSbzD1v-oLW%~pHNg`Wg6K>cj8-#d?M040Gg0^OFPqSc+k zR_y?Lu@l4_8U#9D*s*`nx1$RW!jUoTbgU}Rat8wp9em*WnKSj z0@J9~0kIfVVU-wt4Rc z)OlK97YHmDje)7tgcCr@A`uv9w(&x`8AFvmo|zaP8q)dd6=b*`T2?Xe3mb?SbK!zF zqvm!=P>ZfeIS$>Y(#)6y$u5)Fb2P+)1%^__qh|u49WnxB5_L#5hRL9muBuCU*-E+o zF7B*%1yd%rcbowFuDV9zK*~7+zpMThI|Hj-k9T~L2rZu%cvtAB1uhEN4Km=VYa~F@ z%PJF44%Hyd^V)E0uwx~ZXu$gn6!{AE3t;7IL`*ohsin@7r*?|R&VZR(S^+f|HNl=V29hqX_nAmf zSsnuzl);HUylmM}p@Lr9xot6-a>}a{>xSy!I=xF;c;Jrs3Osm3M6w2g4O^wgnOQR7 zf0(2wj!09*gG7;#bE)pa7yW(+e zuuJgug6GY|;mH}NiRhPaliMCZ(4od~=-0pol_{GgS0-f|Fah!6 zG;P*4%Se|%{?D1aHSrmbjZeN<1&0jVta55gbk<~im&sm@t22884wuS|F>Hl30I4`D z<~0g3j3Z>|#f$#%g*LtIo!kLf@%rWHSh;TJZlK`88`lVdtLp@k!Pg32{aialpCl-m zx^3(iiM#Lg8o?rC5hl;_I%uqZ zMz1=U(9|@(XzEKCNEDbhp+o2h*&u_le>4&riLJR!AmV`uz07&ck?58Q1ID38rP}DA zO$i!~=|$y|4JrWchJ@Y>(kVcb$bXeUApL1)f-0+A2aps5a^4xZCg77&dnAYX+7*0i zKqhww$wYD}5qO5cKhb>aXj`=LN40c~3=UfOYKHx-36!}QtN?DS?^6BjR%Jb*EJ<@` z2$dR)#edHph8&Rt_>S2@4hDD*8$dqTdE$ zEKB#h2*P<@)1Qg#X81X*<&(e&n!YORAaU_WuvoF&`qi~S+!oD%jbv4pDx zBLQd=h`!}z3Ir|3=E*GAGYH9wunR4WVV>|g8FKQS9jzie&7lZtQX-+)3C3W#zBGB>9caR{ca#x zxUDePl|2biTX~$vwVDNv)jkr!K_pS&=~XK|E*@*8Q|C^gy7W~7gpQDp1c*dHAA=^L zVi5GBPzN;$<}4|}fhHYMg^b&SP^+sh{027d^~hlp-*U3c84RH=OPm%&5-lJTvp^{@ zt0-3ONNGjnkY8Zs1bzgzB-0MS$YKH1$s{mJU60S5z-tH4=K;3#KWGX_0{A@vZQ=Te zi2H2e+j`>XbVMt=L^2B@O8nuK6DfGm~4W;zp2h~ zifB5VF4` znDe?UIVt2oBuA`%?&aB1Rz#<1fL7J+7+O07vEU@)VgbSg9x^RChgSvzch07M?$PV zPl!8;2z(`sF|rZ;DXHXiJlc%Vl0f5HVV>|9ndi~_dQ9V(Xo4L&g2Nm{5=5WL5jq9r ziZ=#9k{0?$Uvw}QMy`NMob{B7)NLJ>Lq26oS7IVo%x4H|y|{R2Y6B~Eu{eSs$M9js zM8s!Iw6`zlGl4v8;9386*1j0@E|5u~V0&lKju06As7NM2Bm#kx4vmZe_HAFIh*O3> zelLEk{=Aq3tIm?#%&88)!pLYvbrdRsWO@&={1QULd7q#qS8fSf7L}s~6^`v%;G>GUb zePa@Q3ea-^O(omDFZ@);79Z#9{uZ(6d2vWvAN=Ds&oa`uwWqg_ckR_N6YAmz>x?f0mI8=AZgTwPKlly*$>G+A;8>$UK1}uh+Af*lc2Df zfKf@VbXN`2Z5`%77T??ZWN)pHe9Cs2)JeZyYL1eA%)F2dL;gGflfR{@V&yyNxZ40X{Mr-#y^ASf7PN1)G2j@4a(AJ+*3->(($K_mB0 zBKzoLr>O)Yu__UyS|f>xB1t%P#;yu+(DqGVuZ_ zED-6hAkqz~QCp*Y$JWd%>_K(ur+#tCfR-FbR0%so4&gB`7OpYI)X z1z5{ZU#@l%L?Yycp|Ewz;5uP`p0L9;gXG!59Xz!OKEd;rFMY8qfQf+84{YuV>PG@i zcMg)PU4tchJ5f>j+3iP&cQO<1iBBlONgjHZTfOYQ>n@IOS{0n0!o{w zho)&+QF9gVzU#p3X-5cJ>d=Qz3di{!o+np9Qm41M_zF2fLmRu)fr+-{NfpsJ6|tu; z_~MEXI^7OiNLgMSoxLPWL6-DqGI)ba6zSuQD@382rf%?*I>rlbY|xu_P4A^P6`z?z zWyoZ0jDkvHlq@cUd$tALzS{@VR0PuiX3_k``0$M+K_@n-0SBEr3OZ<4t#K;&{}tEJ V$fSD}8vg(Q002ovPDHLkV1f>Q-lG5j literal 0 HcmV?d00001 diff --git a/TinyDetective/frontend/logos/lazada.svg b/TinyDetective/frontend/logos/lazada.svg new file mode 100644 index 000000000..ac8129eef --- /dev/null +++ b/TinyDetective/frontend/logos/lazada.svg @@ -0,0 +1,425 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TinyDetective/frontend/logos/shopee.png b/TinyDetective/frontend/logos/shopee.png new file mode 100644 index 0000000000000000000000000000000000000000..66bcbf55d85086fb675e324db5040b6e64111989 GIT binary patch literal 1056 zcmV+*1mF9KP)b)>`g81O)c+GD(Xrq?M*E0O)Kt9 zEbdG!>`X21P%!UMFz-Mx>`X1~Of2n8EbmM$>r5=}PA=_EEbdJ#?o2K1ODpY7E9^}w z?oBQ4PA=?9E$>V$=TIo>P%7h;>r5=~O)c$BF7HYz?@cZ5OfFbABxnEt05Wt^ zPE!CNZ=c`q|Ie=wzh7YAFfgy5zt2z*AP}ICfKWisfWN0*3-&|a8o=8^7eyIP1d>QD&QIC0c%d8YQ{<+h%PopP;bKvC;ui|x%Zs#PTVUp zyv{kDe|QTn=?9$s$-t6=8qU(I|31*r4>13I0KTIc%657E>Slj&TZIo`=RD>%h1ftR zfaF&WC;!bYdGX-Z?{NrY?pPggm!DqUM@e+QxJu!jZzGNdgK1I=R(gT-AkVf0t~NeRtA>RUpA}=2va|UwF+i-fF7aOX#x%TfOD|s?DT#`1-6&5E2iK*33xWsOyX8-_1HA2f?>Ux6vOM+op&Ji308o=Ua5!;i z#ykIGJ;1!7%o7ug@4#Yp01doxW<7Q_ST8gM2Lbt!r73PpB;u;wuf+#W9)2U9lo%Ly zg~CX7&@{XlO)R8|=^`JDo(8L*(eDMA{P=z^{aX4(Mti&En3IAo>)slidF@t{}Z$Q3RXjCwCO9ACD)L{6lz;zR>P}MN1VG5$HVMm;R0000