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..dafbc0513
--- /dev/null
+++ b/TinyDetective/README.md
@@ -0,0 +1,125 @@
+# 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
+
+
+
+## TinyFish API usage
+
+TinyDetective uses the official [`tinyfish`](https://pypi.org/project/tinyfish/) Python SDK. The app queues TinyFish runs with `AsyncTinyFish.agent.queue(...)`, then resumes or polls them with `AsyncTinyFish.runs.get(...)` until structured JSON is ready:
+
+```python
+from tinyfish import AsyncTinyFish
+
+client = AsyncTinyFish(api_key=settings.tinyfish_api_key)
+queued = await client.agent.queue(
+ goal=goal,
+ url=url,
+ browser_profile=self._browser_profile(),
+ proxy_config=self._proxy_config(),
+)
+
+run = await client.runs.get(queued.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"}`
+ )}
+
+ `
+ : `
+ 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(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(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) => `
+
+
+ ${(thread.fields || [])
+ .map((field) => `${escapeHtml(field)}`)
+ .join("")}
+ →
+
+
+
${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
+
+ ${getRiskReasonLines(match)
+ .map((line) => `- ${escapeHtml(line)}
`)
+ .join("")}
+
+
+
+
Match reasoning
+
+ ${getMatchReasonLines(match)
+ .map((line) => `- ${escapeHtml(line)}
`)
+ .join("")}
+
+
+ ${
+ 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) => `
+
+
+
+
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
+ ? `
`
+ : '
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..934b781d2
--- /dev/null
+++ b/TinyDetective/frontend/index.html
@@ -0,0 +1,519 @@
+
+
+
+
+
+ TinyDetective
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No investigation running yet.
+
+
+
+
+
+
+
+
+
03
+
+
Step 3
+
Candidate Intake
+
+
+
+
04
+
+
Step 4
+
Reasoning Graph
+
+
+
+
05
+
+
Step 5
+
Ranking Ladder
+
+
+
+
+
+
+
+
+ 01
+
+
+
+
+ Waiting for the official product page.
+
+
+
+
+
+
+
+
+
+
No source URL selected yet.
+
+ Open
+
+
+
+
+
+
+
+
+
+
+
+ 02
+
+
+
+
+ Search queries will appear here as TinyFish fans out across marketplaces.
+
+
+
+
+
+
+
+ 03
+
+
+
+
+ Candidate listings will stream in, then be triaged before deep extraction starts.
+
+
+
+
+
+
+
+ 04
+
+
+
+
+ Comparison signals will assemble here once candidate pages are inspected.
+
+
+
+
+
+
+
+ 05
+
+
+
+
+ Matches will settle into rank as scores firm up.
+
+
+
+
+
+
+
+
+
+
+
+
+ Waiting for an investigation to start.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Idle
+
+
+ No seller case has been started yet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Raw task log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Agent log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Evidence
+
+
+
+
+
+
+
+
+
diff --git a/TinyDetective/frontend/logos/amazon.png b/TinyDetective/frontend/logos/amazon.png
new file mode 100644
index 000000000..dc0e8707b
Binary files /dev/null and b/TinyDetective/frontend/logos/amazon.png differ
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 @@
+
\ 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 @@
+
\ 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 000000000..9245062a8
Binary files /dev/null and b/TinyDetective/frontend/logos/lazada.png differ
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 @@
+
+
+
+
diff --git a/TinyDetective/frontend/logos/shopee.png b/TinyDetective/frontend/logos/shopee.png
new file mode 100644
index 000000000..66bcbf55d
Binary files /dev/null and b/TinyDetective/frontend/logos/shopee.png differ
diff --git a/TinyDetective/frontend/logos/shopee.svg b/TinyDetective/frontend/logos/shopee.svg
new file mode 100644
index 000000000..916b6cdd4
--- /dev/null
+++ b/TinyDetective/frontend/logos/shopee.svg
@@ -0,0 +1,5 @@
+
diff --git a/TinyDetective/frontend/styles.css b/TinyDetective/frontend/styles.css
new file mode 100644
index 000000000..a6187f6d2
--- /dev/null
+++ b/TinyDetective/frontend/styles.css
@@ -0,0 +1,2181 @@
+:root {
+ --bg: oklch(0.965 0.01 80);
+ --bg-deep: oklch(0.93 0.014 75);
+ --bg-accent: oklch(0.9 0.012 200 / 0.36);
+ --surface: color-mix(in oklch, white 90%, var(--bg) 10%);
+ --surface-strong: color-mix(in oklch, white 95%, var(--bg) 5%);
+ --surface-muted: color-mix(in oklch, white 76%, var(--bg-deep) 24%);
+ --line: oklch(0.8 0.012 72 / 0.92);
+ --line-strong: oklch(0.69 0.018 66 / 0.95);
+ --ink: oklch(0.27 0.016 46);
+ --muted: oklch(0.48 0.011 60);
+ --muted-soft: oklch(0.61 0.01 62);
+ --accent: oklch(0.35 0.022 52);
+ --accent-hover: oklch(0.3 0.018 52);
+ --accent-soft: oklch(0.92 0.018 74);
+ --running: oklch(0.9 0.03 82);
+ --running-ink: oklch(0.42 0.035 72);
+ --delayed: oklch(0.93 0.035 92);
+ --delayed-ink: oklch(0.45 0.05 84);
+ --success: oklch(0.93 0.034 150);
+ --success-ink: oklch(0.41 0.055 155);
+ --danger: oklch(0.93 0.03 32);
+ --danger-ink: oklch(0.43 0.055 32);
+ --shadow-soft: 0 30px 80px rgba(75, 60, 42, 0.08);
+ --shadow-tight: 0 12px 28px rgba(75, 60, 42, 0.1);
+ --radius-shell: 28px;
+ --radius-card: 24px;
+ --radius-subcard: 18px;
+ --radius-control: 16px;
+ --radius-tight: 14px;
+ --radius-pill: 999px;
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ color-scheme: light;
+}
+
+body {
+ margin: 0;
+ height: 100svh;
+ font-family: "Manrope", sans-serif;
+ color: var(--ink);
+ line-height: 1.5;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 12% 14%, oklch(0.93 0.024 72 / 0.8), transparent 24%),
+ radial-gradient(circle at 86% 4%, var(--bg-accent), transparent 28%),
+ linear-gradient(180deg, oklch(0.98 0.01 85) 0%, var(--bg) 44%, var(--bg-deep) 100%);
+}
+
+.noise {
+ position: fixed;
+ inset: 0;
+ opacity: 0.2;
+ pointer-events: none;
+ background-image: radial-gradient(rgba(87, 70, 54, 0.08) 0.6px, transparent 0.6px);
+ background-size: 12px 12px;
+ mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.18), transparent 80%);
+}
+
+a {
+ color: var(--accent);
+ text-decoration-color: color-mix(in oklch, var(--accent) 35%, transparent 65%);
+ text-underline-offset: 0.18em;
+}
+
+a:hover {
+ color: var(--accent-hover);
+}
+
+a:focus-visible,
+button:focus-visible,
+summary:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 4px color-mix(in oklch, var(--accent-soft) 82%, transparent 18%);
+}
+
+[hidden] {
+ display: none !important;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.shell {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ height: 400svh;
+ transition: transform 950ms var(--ease-out);
+ will-change: transform;
+}
+
+body[data-phase="prompt"] .shell {
+ transform: translateY(0);
+}
+
+body[data-phase="progress"] .shell {
+ transform: translateY(-100svh);
+}
+
+body[data-phase="report"] .shell {
+ transform: translateY(-200svh);
+}
+
+body[data-phase="case"] .shell {
+ transform: translateY(-300svh);
+}
+
+.scene {
+ height: 100svh;
+ padding: clamp(24px, 4vw, 52px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.scene-inner {
+ width: min(1080px, 100%);
+}
+
+.prompt-inner {
+ display: grid;
+ gap: clamp(22px, 3vw, 34px);
+ justify-items: center;
+ text-align: center;
+}
+
+.prompt-header {
+ display: grid;
+ gap: 10px;
+ justify-items: center;
+}
+
+.prompt-brand {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: clamp(14px, 1.8vw, 22px);
+}
+
+.prompt-brand-mark {
+ display: block;
+ width: clamp(74px, 6.6vw, 102px);
+ flex: 0 0 auto;
+}
+
+.prompt-brand-logo {
+ display: block;
+ width: 100%;
+ height: auto;
+ object-fit: contain;
+ opacity: 0.94;
+}
+
+.prompt-brand-copy {
+ display: block;
+ text-align: left;
+}
+
+.eyebrow,
+.section-label {
+ margin: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ font-size: 0.72rem;
+ color: var(--muted-soft);
+}
+
+h1,
+h2 {
+ margin: 0;
+ font-family: "Instrument Serif", serif;
+ font-weight: 400;
+ line-height: 0.98;
+ letter-spacing: -0.02em;
+}
+
+h1 {
+ font-size: clamp(3.3rem, 9vw, 6.1rem);
+}
+
+h2 {
+ font-size: clamp(2.2rem, 4.5vw, 3.2rem);
+}
+
+.prompt-form {
+ width: min(900px, 100%);
+}
+
+.composer {
+ display: grid;
+ gap: 12px;
+ padding: clamp(16px, 2vw, 20px);
+ border: 1px solid color-mix(in oklch, var(--line) 88%, white 12%);
+ border-radius: var(--radius-card);
+ background: color-mix(in oklch, white 94%, var(--bg) 6%);
+ box-shadow:
+ 0 22px 60px rgba(75, 60, 42, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.76);
+ transition:
+ border-color 180ms var(--ease-out),
+ box-shadow 180ms var(--ease-out),
+ transform 180ms var(--ease-out);
+}
+
+.composer:focus-within {
+ border-color: color-mix(in oklch, var(--line) 88%, white 12%);
+ box-shadow:
+ 0 22px 60px rgba(75, 60, 42, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.82);
+}
+
+.composer.is-invalid {
+ border-color: color-mix(in oklch, var(--danger-ink) 50%, var(--line) 50%);
+ box-shadow:
+ var(--shadow-soft),
+ 0 0 0 4px color-mix(in oklch, var(--danger) 54%, transparent 46%);
+}
+
+textarea {
+ width: 100%;
+ border: 0;
+ padding: 4px 2px 0;
+ background: transparent;
+ color: var(--ink);
+ font: inherit;
+ resize: none;
+}
+
+textarea:focus {
+ outline: none;
+ box-shadow: none;
+}
+
+#source-urls {
+ min-height: 96px;
+ max-height: 240px;
+ font-size: clamp(1.05rem, 1.45vw, 1.16rem);
+ line-height: 1.65;
+}
+
+textarea:disabled {
+ color: color-mix(in oklch, var(--muted) 82%, var(--ink) 18%);
+ cursor: not-allowed;
+}
+
+textarea::placeholder {
+ color: color-mix(in oklch, var(--muted) 84%, white 16%);
+}
+
+.composer-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+}
+
+.composer-archives {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-right: auto;
+}
+
+.composer-history {
+ position: relative;
+}
+
+.composer-secondary-action {
+ min-width: 126px;
+}
+
+.history-dropdown {
+ position: absolute;
+ left: 0;
+ bottom: calc(100% + 12px);
+ z-index: 6;
+ width: min(420px, calc(100vw - 72px));
+ max-height: min(70vh, 560px);
+ display: grid;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid color-mix(in oklch, var(--line) 82%, white 18%);
+ border-radius: calc(var(--radius-card) - 4px);
+ background: color-mix(in oklch, white 95%, var(--bg) 5%);
+ box-shadow:
+ 0 18px 36px rgba(75, 60, 42, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.82);
+ text-align: left;
+}
+
+.history-dropdown--cases {
+ left: auto;
+ right: 0;
+}
+
+.history-dropdown-header {
+ display: grid;
+ gap: 4px;
+}
+
+.history-dropdown-caption {
+ color: var(--muted);
+ font-size: 0.88rem;
+ line-height: 1.45;
+}
+
+button {
+ min-height: 50px;
+ padding: 13px 22px;
+ border: 1px solid color-mix(in oklch, var(--accent) 72%, black 28%);
+ border-radius: var(--radius-subcard);
+ background: var(--accent);
+ color: oklch(0.97 0.008 80);
+ font: inherit;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ cursor: pointer;
+ box-shadow: var(--shadow-tight);
+ transition:
+ background-color 180ms var(--ease-out),
+ border-color 180ms var(--ease-out),
+ box-shadow 180ms var(--ease-out),
+ transform 180ms var(--ease-out);
+}
+
+button:hover {
+ background: var(--accent-hover);
+ border-color: color-mix(in oklch, var(--accent-hover) 76%, black 24%);
+ transform: translateY(-1px);
+}
+
+button:active {
+ transform: translateY(0);
+ box-shadow: 0 6px 14px rgba(76, 61, 47, 0.12);
+}
+
+button[disabled] {
+ cursor: wait;
+ background: color-mix(in oklch, var(--accent) 72%, white 28%);
+ border-color: color-mix(in oklch, var(--line-strong) 48%, var(--accent) 52%);
+ box-shadow: none;
+ transform: none;
+ opacity: 0.8;
+}
+
+.logo-marquee {
+ width: min(67%, 600px);
+ min-width: min(100%, 360px);
+ overflow: hidden;
+ mask-image: linear-gradient(90deg, transparent, black 10%, black 90%, transparent);
+}
+
+.logo-track {
+ display: flex;
+ gap: 18px;
+ width: max-content;
+ align-items: center;
+ animation: marquee 34s linear infinite;
+}
+
+.logo-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 68px;
+ min-width: 68px;
+ height: 52px;
+ opacity: 0.5;
+}
+
+.logo-mark img {
+ display: block;
+ width: 36px;
+ height: 36px;
+ object-fit: contain;
+ object-position: center;
+ filter: grayscale(1) saturate(0) brightness(0.76) contrast(1.02);
+}
+
+.logo-mark--facebook img {
+ width: 31px;
+ height: 31px;
+}
+
+.logo-mark--amazon img {
+ width: 35px;
+ height: 35px;
+}
+
+.logo-mark--ebay img {
+ width: 42px;
+ height: 42px;
+}
+
+.logo-mark--lazada img {
+ width: 38px;
+ height: 38px;
+}
+
+@keyframes marquee {
+ from {
+ transform: translateX(0);
+ }
+
+ to {
+ transform: translateX(calc(-50% - 7px));
+ }
+}
+
+.progress-inner {
+ display: grid;
+ align-items: stretch;
+ justify-items: center;
+ width: 100%;
+ height: 100%;
+}
+
+.progress-scene,
+.report-scene,
+.case-scene {
+ align-items: stretch;
+}
+
+.panel {
+ position: relative;
+ overflow: hidden;
+ width: min(960px, 100%);
+ padding: clamp(24px, 3.2vw, 34px);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-shell);
+ background: linear-gradient(
+ 180deg,
+ color-mix(in oklch, white 90%, var(--bg) 10%),
+ color-mix(in oklch, white 82%, var(--bg-deep) 18%)
+ );
+ box-shadow: var(--shadow-soft);
+}
+
+.progress-panel {
+ display: grid;
+ gap: 20px;
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ width: min(1080px, 100%);
+ height: 100%;
+ max-height: 100%;
+ overflow: hidden;
+}
+
+.report-inner {
+ display: grid;
+ align-items: stretch;
+ justify-items: center;
+ width: 100%;
+ height: 100%;
+}
+
+.report-panel {
+ display: grid;
+ gap: 18px;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ width: min(1120px, 100%);
+ height: 100%;
+ max-height: 100%;
+ overflow: hidden;
+}
+
+.case-inner {
+ display: grid;
+ align-items: stretch;
+ justify-items: center;
+ width: 100%;
+ height: 100%;
+}
+
+.case-panel {
+ display: grid;
+ gap: 20px;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ width: min(1180px, 100%);
+ height: 100%;
+ max-height: 100%;
+ overflow: hidden;
+}
+
+.panel::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
+ pointer-events: none;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 18px;
+ margin-bottom: 10px;
+}
+
+.report-header {
+ margin-bottom: 0;
+}
+
+.status-pill {
+ min-width: 84px;
+ padding: 10px 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: var(--surface-strong);
+ color: var(--muted);
+ font-size: 0.86rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ text-align: center;
+ white-space: nowrap;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
+}
+
+.status-pill[data-status="idle"],
+.status-pill[data-status="queued"] {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
+.status-pill[data-status="running"] {
+ background: var(--running);
+ color: var(--running-ink);
+}
+
+.status-pill[data-status="delayed"] {
+ background: var(--delayed);
+ color: var(--delayed-ink);
+}
+
+.status-pill[data-status="completed"] {
+ background: var(--success);
+ color: var(--success-ink);
+}
+
+.status-pill[data-status="failed"] {
+ background: var(--danger);
+ color: var(--danger-ink);
+}
+
+.score-grid,
+.evidence-list,
+.agent-log-content,
+#results {
+ display: grid;
+ gap: 12px;
+}
+
+.progress-overview {
+ display: grid;
+ gap: 10px;
+ width: 100%;
+ max-width: none;
+}
+
+.progress-track {
+ position: relative;
+ width: 100%;
+ height: 10px;
+ overflow: hidden;
+ border-radius: var(--radius-pill);
+ border: 1px solid color-mix(in oklch, var(--line) 72%, white 28%);
+ background: color-mix(in oklch, var(--surface-muted) 88%, white 12%);
+}
+
+.progress-fill {
+ height: 100%;
+ width: 0;
+ border-radius: inherit;
+ background: linear-gradient(
+ 90deg,
+ color-mix(in oklch, var(--accent) 72%, white 28%),
+ color-mix(in oklch, var(--running-ink) 84%, white 16%)
+ );
+ transition: width 220ms var(--ease-out);
+}
+
+.progress-overview-text {
+ margin: 0;
+ font-size: 0.98rem;
+ font-weight: 650;
+ line-height: 1.55;
+}
+
+.timeline-shell {
+ display: grid;
+ grid-template-columns: 240px minmax(0, 1fr);
+ gap: 24px;
+ align-items: stretch;
+ min-height: 0;
+ height: 100%;
+}
+
+.timeline-rail {
+ position: relative;
+ display: grid;
+ align-content: start;
+ align-self: start;
+ gap: 8px;
+ min-height: 0;
+ padding: 6px 18px 6px 0;
+}
+
+.timeline-rail-step {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ gap: 14px;
+ align-items: center;
+ min-height: 68px;
+ padding: 8px 0;
+ opacity: 0.58;
+ transition: opacity 280ms var(--ease-out);
+}
+
+.timeline-rail-step::before,
+.timeline-rail-step::after {
+ content: "";
+ position: absolute;
+ left: 21px;
+ width: 1px;
+ background: color-mix(in oklch, var(--line) 74%, white 26%);
+}
+
+.timeline-rail-step::before {
+ top: -8px;
+ bottom: calc(50% + 21px);
+}
+
+.timeline-rail-step::after {
+ top: calc(50% + 21px);
+ bottom: -8px;
+}
+
+.timeline-rail-step:first-child::before {
+ display: none;
+}
+
+.timeline-rail-step:last-child::after {
+ display: none;
+}
+
+.timeline-rail-step.is-active::before,
+.timeline-rail-step.is-active::after {
+ background: color-mix(in oklch, var(--line-strong) 18%, white 82%);
+}
+
+.timeline-rail-step.is-active {
+ opacity: 1;
+}
+
+.timeline-rail-number,
+.timeline-number {
+ position: relative;
+ z-index: 1;
+ display: inline-grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border: 1px solid color-mix(in oklch, var(--line) 86%, white 14%);
+ border-radius: var(--radius-control);
+ background: color-mix(in oklch, white 92%, var(--bg) 8%);
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ transition:
+ background-color 280ms var(--ease-out),
+ border-color 280ms var(--ease-out),
+ color 280ms var(--ease-out),
+ transform 620ms var(--ease-out),
+ box-shadow 620ms var(--ease-out);
+}
+
+.timeline-rail-step.is-active .timeline-rail-number {
+ transform: scale(1.06);
+ box-shadow: 0 18px 34px rgba(75, 60, 42, 0.12);
+}
+
+.timeline-rail-copy {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.timeline-rail-copy strong {
+ font-size: 0.98rem;
+ line-height: 1.32;
+ font-weight: 700;
+ color: color-mix(in oklch, var(--ink) 82%, white 18%);
+}
+
+.timeline-viewport {
+ position: relative;
+ align-self: stretch;
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+ overflow: hidden;
+ border-radius: var(--radius-card);
+}
+
+.timeline-track {
+ height: 100%;
+ display: grid;
+ grid-auto-rows: 100%;
+ transition: transform 1100ms var(--ease-out);
+ will-change: transform;
+}
+
+.timeline-step {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-items: stretch;
+ min-height: 0;
+ height: 100%;
+}
+
+.timeline-marker {
+ display: none;
+}
+
+.timeline-card {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 14px;
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+ overflow: hidden;
+ padding: 22px;
+ border: 1px solid color-mix(in oklch, var(--line) 84%, white 16%);
+ border-radius: var(--radius-card);
+ background: color-mix(in oklch, var(--surface-strong) 84%, var(--surface-muted) 16%);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.64),
+ 0 18px 40px rgba(75, 60, 42, 0.06);
+}
+
+.timeline-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 18px;
+}
+
+.timeline-kicker {
+ margin: 0 0 6px;
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.timeline-card h3 {
+ margin: 0;
+ font-size: 1.2rem;
+ line-height: 1.2;
+ font-weight: 700;
+}
+
+.timeline-step-state {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: fit-content;
+ min-width: 88px;
+ padding: 7px 12px;
+ border-radius: var(--radius-pill);
+ background: var(--surface-strong);
+ color: var(--muted);
+ font-size: 0.77rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.timeline-step-note {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.94rem;
+ line-height: 1.6;
+}
+
+.timeline-rail-step[data-status="queued"],
+.timeline-rail-step[data-status="queued"] .timeline-rail-number,
+.timeline-step[data-status="queued"] .timeline-number,
+.timeline-step[data-status="queued"] .timeline-step-state {
+ color: var(--accent);
+}
+
+.timeline-rail-step[data-status="running"] .timeline-rail-number,
+.timeline-step[data-status="running"] .timeline-number,
+.timeline-step[data-status="running"] .timeline-step-state {
+ background: var(--running);
+ border-color: color-mix(in oklch, var(--running-ink) 34%, var(--line) 66%);
+ color: var(--running-ink);
+}
+
+.timeline-rail-step[data-status="delayed"] .timeline-rail-number,
+.timeline-step[data-status="delayed"] .timeline-number,
+.timeline-step[data-status="delayed"] .timeline-step-state {
+ background: var(--delayed);
+ border-color: color-mix(in oklch, var(--delayed-ink) 34%, var(--line) 66%);
+ color: var(--delayed-ink);
+}
+
+.timeline-rail-step[data-status="completed"] .timeline-rail-number,
+.timeline-step[data-status="completed"] .timeline-number,
+.timeline-step[data-status="completed"] .timeline-step-state {
+ background: var(--success);
+ border-color: color-mix(in oklch, var(--success-ink) 36%, var(--line) 64%);
+ color: var(--success-ink);
+}
+
+.timeline-rail-step[data-status="failed"] .timeline-rail-number,
+.timeline-step[data-status="failed"] .timeline-number,
+.timeline-step[data-status="failed"] .timeline-step-state {
+ background: var(--danger);
+ border-color: color-mix(in oklch, var(--danger-ink) 38%, var(--line) 62%);
+ color: var(--danger-ink);
+}
+
+.source-stage,
+.analysis-stage {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.8fr);
+ gap: 14px;
+ min-height: 0;
+ height: 100%;
+ align-items: stretch;
+}
+
+.source-stage > *,
+.analysis-stage > * {
+ min-height: 0;
+ height: 100%;
+}
+
+.browser-frame,
+.source-meta,
+.search-log-item,
+.candidate-card,
+.signal-graph,
+.analysis-log,
+.ranking-item,
+.summary-card {
+ border: 1px solid color-mix(in oklch, var(--line) 84%, white 16%);
+ background: color-mix(in oklch, white 90%, var(--bg) 10%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58);
+}
+
+.browser-frame,
+.source-meta,
+.signal-graph,
+.analysis-log,
+.summary-card {
+ border-radius: var(--radius-subcard);
+}
+
+.browser-frame {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ overflow: hidden;
+ min-height: 0;
+ height: 100%;
+}
+
+.browser-bar {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border-bottom: 1px solid color-mix(in oklch, var(--line) 84%, white 16%);
+ background: color-mix(in oklch, white 92%, var(--bg) 8%);
+}
+
+.browser-chrome {
+ display: inline-flex;
+ gap: 6px;
+}
+
+.browser-chrome span {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-pill);
+ background: color-mix(in oklch, var(--line-strong) 36%, white 64%);
+}
+
+.browser-url {
+ margin: 0;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.83rem;
+ color: var(--muted);
+}
+
+.browser-open {
+ font-size: 0.8rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.browser-preview {
+ display: block;
+ width: 100%;
+ min-height: 0;
+ height: 100%;
+ border: 0;
+ background: linear-gradient(
+ 180deg,
+ color-mix(in oklch, white 84%, var(--bg) 16%),
+ color-mix(in oklch, white 76%, var(--bg-deep) 24%)
+ );
+}
+
+.source-meta {
+ display: grid;
+ align-content: start;
+ gap: 10px;
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+ padding: 16px;
+}
+
+.meta-label {
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.meta-title {
+ font-size: 1.05rem;
+ font-weight: 700;
+ line-height: 1.35;
+}
+
+.meta-grid {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.meta-grid div {
+ min-width: 0;
+}
+
+.meta-grid strong {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 0.72rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.search-log,
+.candidate-stream,
+.analysis-log {
+ display: grid;
+ gap: 12px;
+ align-content: start;
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+}
+
+.search-log-item,
+.analysis-log-item {
+ padding: 14px 16px;
+ border-radius: var(--radius-subcard);
+}
+
+.search-log-header,
+.analysis-log-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.search-query,
+.analysis-log-title {
+ font-weight: 700;
+ line-height: 1.4;
+}
+
+.search-log-meta,
+.analysis-log-meta,
+.analysis-log-text {
+ margin: 6px 0 0;
+ color: var(--muted);
+ font-size: 0.88rem;
+ line-height: 1.55;
+}
+
+.candidate-stream {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.candidate-card {
+ display: grid;
+ gap: 10px;
+ padding: 16px;
+ border-radius: var(--radius-subcard);
+}
+
+.candidate-card-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: baseline;
+}
+
+.candidate-marketplace,
+.candidate-query {
+ font-size: 0.77rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.candidate-title {
+ margin: 0;
+ font-size: 0.96rem;
+ line-height: 1.45;
+ font-weight: 650;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.candidate-link {
+ color: var(--muted);
+ font-size: 0.84rem;
+ line-height: 1.45;
+ word-break: break-word;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.candidate-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.candidate-chip,
+.graph-chip,
+.ranking-chip {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ max-width: 100%;
+ padding: 6px 10px;
+ border-radius: var(--radius-pill);
+ border: 1px solid color-mix(in oklch, var(--line) 78%, white 22%);
+ background: color-mix(in oklch, white 94%, var(--surface-muted) 6%);
+ color: color-mix(in oklch, var(--ink) 72%, white 28%);
+ font-size: 0.76rem;
+ font-weight: 650;
+ letter-spacing: 0.01em;
+ text-align: left;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
+}
+
+.signal-graph {
+ display: grid;
+ gap: 12px;
+ align-content: start;
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+ padding: 16px;
+}
+
+.graph-source-node,
+.graph-candidate {
+ display: grid;
+ gap: 6px;
+ padding: 14px;
+ border-radius: var(--radius-subcard);
+ background: color-mix(in oklch, white 94%, var(--bg) 6%);
+}
+
+.graph-source-node strong,
+.graph-candidate strong {
+ font-size: 0.8rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.graph-thread {
+ display: grid;
+ gap: 10px;
+ padding: 14px;
+ border-radius: var(--radius-subcard);
+ background: color-mix(in oklch, white 95%, var(--surface-muted) 5%);
+}
+
+.graph-link {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.graph-arrow {
+ color: var(--muted-soft);
+ font-weight: 700;
+}
+
+.graph-reason {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.55;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.ranking-ladder {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ align-content: start;
+ min-height: 0;
+ height: 100%;
+ overflow: auto;
+}
+
+.ranking-item {
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) 32px;
+ grid-template-areas:
+ "rank main toggle"
+ ". details details";
+ align-items: start;
+ gap: 14px;
+ padding: 14px 16px;
+ border-radius: var(--radius-subcard);
+}
+
+.ranking-rank {
+ grid-area: rank;
+ display: inline-grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-tight);
+ background: color-mix(in oklch, var(--accent-soft) 54%, white 46%);
+ color: var(--accent);
+ font-weight: 800;
+}
+
+.ranking-main {
+ grid-area: main;
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.ranking-title {
+ margin: 0 0 4px;
+ font-size: 0.96rem;
+ font-weight: 700;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.ranking-url {
+ color: var(--muted);
+ font-size: 0.84rem;
+ line-height: 1.45;
+ word-break: break-word;
+}
+
+.ranking-url-link {
+ color: var(--muted);
+ text-decoration-color: color-mix(in oklch, var(--line-strong) 44%, transparent 56%);
+}
+
+.ranking-url-link:hover {
+ color: var(--ink);
+}
+
+.ranking-metadata {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ min-width: 0;
+ align-items: flex-start;
+}
+
+.ranking-metadata > * {
+ min-width: 0;
+}
+
+.ranking-metric {
+ display: inline-flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-width: 0;
+}
+
+.ranking-metric-label {
+ font-size: 0.72rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.ranking-metric-value {
+ color: var(--ink);
+ font-size: 0.84rem;
+ font-weight: 700;
+}
+
+.ranking-toggle {
+ grid-area: toggle;
+ align-self: start;
+ display: inline-grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ min-height: 32px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.ranking-toggle:hover {
+ background: transparent;
+ transform: none;
+}
+
+.ranking-toggle:active {
+ transform: none;
+ box-shadow: none;
+}
+
+.ranking-chevron {
+ width: 10px;
+ height: 10px;
+ border-right: 2px solid color-mix(in oklch, var(--ink) 72%, white 28%);
+ border-bottom: 2px solid color-mix(in oklch, var(--ink) 72%, white 28%);
+ transform: rotate(45deg);
+ transition: transform 180ms var(--ease-out);
+}
+
+.ranking-item.is-open .ranking-chevron {
+ transform: rotate(225deg);
+}
+
+.ranking-details-body {
+ grid-area: details;
+ display: grid;
+ gap: 12px;
+ margin-top: 2px;
+ padding-top: 12px;
+ border-top: 1px solid color-mix(in oklch, var(--line) 80%, white 20%);
+}
+
+.ranking-detail-actions {
+ display: flex;
+ justify-content: flex-start;
+ padding-top: 4px;
+}
+
+.ranking-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 4px;
+}
+
+button.report-action {
+ min-height: 40px;
+ padding: 10px 16px;
+ border: 1px solid color-mix(in oklch, var(--line-strong) 24%, var(--line) 76%);
+ border-radius: var(--radius-subcard);
+ background: color-mix(in oklch, white 95%, var(--surface-muted) 5%);
+ color: var(--ink);
+ box-shadow: none;
+}
+
+button.report-action:hover {
+ background: color-mix(in oklch, white 92%, var(--surface-muted) 8%);
+ border-color: color-mix(in oklch, var(--line-strong) 36%, var(--line) 64%);
+ transform: none;
+}
+
+button.report-action:active {
+ transform: none;
+ box-shadow: none;
+}
+
+button.panel-action {
+ min-height: 40px;
+ padding: 10px 16px;
+ border: 1px solid color-mix(in oklch, var(--line-strong) 24%, var(--line) 76%);
+ border-radius: var(--radius-subcard);
+ background: color-mix(in oklch, white 95%, var(--surface-muted) 5%);
+ color: var(--ink);
+ box-shadow: none;
+}
+
+button.panel-action:hover {
+ background: color-mix(in oklch, white 92%, var(--surface-muted) 8%);
+ border-color: color-mix(in oklch, var(--line-strong) 36%, var(--line) 64%);
+ transform: none;
+}
+
+button.panel-action:active {
+ transform: none;
+ box-shadow: none;
+}
+
+.ranking-reason {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.6;
+ overflow-wrap: anywhere;
+}
+
+.ranking-detail-group {
+ display: grid;
+ gap: 8px;
+}
+
+.ranking-detail-list {
+ margin: 0;
+ padding-left: 18px;
+ color: var(--muted);
+ font-size: 0.88rem;
+ line-height: 1.6;
+}
+
+.ranking-detail-list li + li {
+ margin-top: 6px;
+}
+
+.ranking-detail-group strong,
+.ranking-evidence-field {
+ font-size: 0.72rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.ranking-detail-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.ranking-evidence-list {
+ display: grid;
+ gap: 10px;
+}
+
+.ranking-evidence-item {
+ display: grid;
+ gap: 4px;
+}
+
+.ranking-evidence-item p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.88rem;
+ line-height: 1.55;
+}
+
+.summary-card {
+ padding: 18px;
+ min-height: 112px;
+ height: 100%;
+ overflow: auto;
+ color: var(--ink);
+ font-size: 0.98rem;
+ line-height: 1.7;
+}
+
+.source-meta,
+.search-log,
+.candidate-stream,
+.signal-graph,
+.analysis-log,
+.ranking-ladder,
+.summary-card {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklch, var(--line-strong) 42%, white 58%) transparent;
+}
+
+.source-meta::-webkit-scrollbar,
+.search-log::-webkit-scrollbar,
+.candidate-stream::-webkit-scrollbar,
+.signal-graph::-webkit-scrollbar,
+.analysis-log::-webkit-scrollbar,
+.ranking-ladder::-webkit-scrollbar,
+.summary-card::-webkit-scrollbar {
+ width: 8px;
+}
+
+.source-meta::-webkit-scrollbar-thumb,
+.search-log::-webkit-scrollbar-thumb,
+.candidate-stream::-webkit-scrollbar-thumb,
+.signal-graph::-webkit-scrollbar-thumb,
+.analysis-log::-webkit-scrollbar-thumb,
+.ranking-ladder::-webkit-scrollbar-thumb,
+.summary-card::-webkit-scrollbar-thumb {
+ border-radius: var(--radius-pill);
+ background: color-mix(in oklch, var(--line-strong) 28%, white 72%);
+}
+
+.progress-text,
+.empty-state {
+ color: var(--muted);
+}
+
+.progress-text {
+ margin: 0;
+ font-size: 0.96rem;
+ line-height: 1.65;
+}
+
+.report-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.report-meta {
+ display: grid;
+ gap: 6px;
+}
+
+.report-note,
+.report-caption {
+ margin: 0;
+}
+
+.report-note {
+ color: var(--ink);
+ font-size: 1rem;
+ font-weight: 650;
+ line-height: 1.55;
+}
+
+.report-caption {
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.6;
+}
+
+.report-frame-shell {
+ position: relative;
+ min-height: 0;
+ height: 100%;
+ overflow: hidden;
+ border: 1px solid color-mix(in oklch, var(--line) 84%, white 16%);
+ border-radius: var(--radius-card);
+ background:
+ linear-gradient(180deg, color-mix(in oklch, white 94%, var(--bg) 6%), color-mix(in oklch, white 88%, var(--bg-deep) 12%));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.64),
+ 0 18px 40px rgba(75, 60, 42, 0.06);
+}
+
+.report-frame-shell::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.64);
+}
+
+.report-pdf-frame {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: color-mix(in oklch, white 92%, var(--bg) 8%);
+}
+
+.past-runs-list {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+ max-height: min(52vh, 360px);
+ overflow: auto;
+ padding-right: 4px;
+}
+
+.past-run-item {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ justify-content: flex-start;
+ gap: 10px;
+ width: 100%;
+ min-height: 108px;
+ height: auto;
+ padding: 16px 18px;
+ border: 1px solid color-mix(in oklch, var(--line) 84%, white 16%);
+ border-radius: var(--radius-control);
+ appearance: none;
+ background: color-mix(in oklch, var(--surface-strong) 84%, var(--surface-muted) 16%);
+ color: var(--ink);
+ text-align: left;
+ overflow: visible;
+ box-shadow: none;
+ transition:
+ background-color 180ms var(--ease-out),
+ border-color 180ms var(--ease-out),
+ box-shadow 180ms var(--ease-out),
+ transform 180ms var(--ease-out);
+}
+
+.past-run-item.is-active {
+ border-color: color-mix(in oklch, var(--accent) 22%, var(--line) 78%);
+ background: color-mix(in oklch, var(--accent-soft) 38%, var(--surface-strong) 62%);
+}
+
+.past-run-item:hover {
+ background: color-mix(in oklch, white 87%, var(--accent-soft) 13%);
+ border-color: color-mix(in oklch, var(--accent) 18%, var(--line) 82%);
+ transform: none;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
+}
+
+.past-run-item.is-active:hover {
+ background: color-mix(in oklch, var(--accent-soft) 42%, var(--surface-strong) 58%);
+ border-color: color-mix(in oklch, var(--accent) 24%, var(--line) 76%);
+}
+
+.past-run-item:active {
+ transform: translateY(0);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
+}
+
+.past-run-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 12px;
+ font-size: 0.8rem;
+}
+
+.past-run-status {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ padding: 4px 10px;
+ border-radius: var(--radius-pill);
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-weight: 650;
+ letter-spacing: 0.02em;
+}
+
+.past-run-status[data-status="running"] {
+ background: var(--running);
+ color: var(--running-ink);
+}
+
+.past-run-status[data-status="delayed"] {
+ background: var(--delayed);
+ color: var(--delayed-ink);
+}
+
+.past-run-status[data-status="completed"] {
+ background: var(--success);
+ color: var(--success-ink);
+}
+
+.past-run-status[data-status="failed"] {
+ background: var(--danger);
+ color: var(--danger-ink);
+}
+
+.past-run-time {
+ margin-left: auto;
+ color: var(--muted-soft);
+ white-space: nowrap;
+ text-align: right;
+ font-weight: 600;
+}
+
+.past-run-title {
+ display: block;
+ margin: 0;
+ font-size: 0.92rem;
+ font-weight: 600;
+ line-height: 1.28;
+ letter-spacing: -0.01em;
+ overflow-wrap: anywhere;
+}
+
+.past-run-meta {
+ display: block;
+ color: var(--muted);
+ font-size: 0.82rem;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.past-run-meta[data-tone="error"] {
+ color: var(--danger-ink);
+}
+
+.report-card,
+.match-card {
+ display: grid;
+ gap: 14px;
+ padding: 20px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-card);
+ background: var(--surface-strong);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
+}
+
+.report-card + .report-card,
+.match-card + .match-card {
+ margin-top: 16px;
+}
+
+.report-summary {
+ font-size: 1.05rem;
+ font-weight: 700;
+ line-height: 1.65;
+}
+
+.report-source,
+.match-header,
+.score-chip,
+.signal,
+.evidence-item,
+.agent-log-item,
+code {
+ padding: 12px 14px;
+ border: 1px solid color-mix(in oklch, var(--line) 85%, transparent 15%);
+ border-radius: var(--radius-control);
+ background: var(--surface-muted);
+}
+
+.report-source,
+.match-header,
+.evidence-item,
+.agent-log-item,
+code {
+ overflow-wrap: anywhere;
+}
+
+.report-source {
+ line-height: 1.6;
+}
+
+.matches {
+ display: grid;
+ gap: 12px;
+}
+
+.score-grid {
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+}
+
+.score-chip strong {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: var(--muted-soft);
+}
+
+.reason {
+ margin: 0;
+ line-height: 1.65;
+}
+
+.match-actions {
+ display: flex;
+ justify-content: flex-start;
+}
+
+.case-action {
+ min-height: 42px;
+ padding: 10px 16px;
+ border-radius: var(--radius-control);
+ font-size: 0.94rem;
+}
+
+.ranking-case-action {
+ min-height: 40px;
+ padding: 10px 16px;
+ margin-left: auto;
+ box-shadow: none;
+}
+
+.signals {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.signal {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ font-size: 0.9rem;
+ color: var(--danger-ink);
+ background: color-mix(in oklch, var(--danger) 68%, white 32%);
+ border-color: color-mix(in oklch, var(--danger-ink) 16%, var(--danger) 84%);
+}
+
+details {
+ margin-top: 2px;
+}
+
+summary {
+ list-style: none;
+ cursor: pointer;
+ font-weight: 700;
+ color: var(--ink);
+}
+
+summary::-webkit-details-marker {
+ display: none;
+}
+
+summary::after {
+ float: right;
+ content: "+";
+ color: var(--muted-soft);
+ transition: transform 180ms var(--ease-out);
+}
+
+details[open] > summary::after {
+ transform: rotate(45deg);
+}
+
+.agent-log {
+ padding-top: 12px;
+ border-top: 1px solid color-mix(in oklch, var(--line) 80%, transparent 20%);
+}
+
+.agent-log-content,
+.evidence-list {
+ margin-top: 12px;
+}
+
+code {
+ display: block;
+ color: color-mix(in oklch, var(--ink) 88%, black 12%);
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 0.84rem;
+ line-height: 1.55;
+ font-family:
+ ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ Monaco,
+ Consolas,
+ "Liberation Mono",
+ monospace;
+}
+
+.empty-state {
+ max-width: 56ch;
+ margin: 0;
+ font-size: 0.95rem;
+ line-height: 1.6;
+}
+
+#results > .empty-state {
+ padding: 18px 0 8px;
+}
+
+.case-subtitle {
+ max-width: 70ch;
+ margin: 10px 0 0;
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.case-overview {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 18px;
+ align-items: center;
+ padding: 18px 20px;
+ border: 1px solid color-mix(in oklch, var(--line) 86%, white 14%);
+ border-radius: var(--radius-card);
+ background: color-mix(in oklch, white 88%, var(--bg-deep) 12%);
+}
+
+.case-progress-copy {
+ display: grid;
+ gap: 10px;
+}
+
+.case-grid {
+ min-height: 0;
+ overflow: auto;
+ padding-right: 4px;
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.case-section {
+ display: grid;
+ gap: 14px;
+ align-content: start;
+ padding: 20px;
+ border: 1px solid color-mix(in oklch, var(--line) 86%, white 14%);
+ border-radius: var(--radius-card);
+ background: var(--surface-strong);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
+}
+
+.case-section--profile,
+.case-section--seed {
+ grid-column: span 6;
+}
+
+.case-section--listings,
+.case-section--draft {
+ grid-column: span 7;
+}
+
+.case-section--evidence,
+.case-section--activity {
+ grid-column: span 5;
+}
+
+.case-section-header {
+ display: grid;
+ gap: 6px;
+}
+
+.case-section-header h3 {
+ margin: 0;
+ font-size: 1.18rem;
+ line-height: 1.2;
+}
+
+.case-profile-summary,
+.case-seed-summary,
+.case-draft,
+.case-activity-log,
+.case-suspect-listings,
+.case-evidence-grid {
+ display: grid;
+ gap: 12px;
+}
+
+.case-stat-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px;
+}
+
+.case-stat,
+.case-seed-card,
+.case-listing-card,
+.case-evidence-card,
+.case-activity-item,
+.case-draft-block {
+ padding: 14px 16px;
+ border: 1px solid color-mix(in oklch, var(--line) 86%, transparent 14%);
+ border-radius: var(--radius-control);
+ background: var(--surface-muted);
+ overflow-wrap: anywhere;
+}
+
+.case-stat strong,
+.case-listing-card strong,
+.case-evidence-card strong,
+.case-draft-block strong {
+ display: block;
+ margin-bottom: 4px;
+}
+
+.case-muted {
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.case-listing-card {
+ display: grid;
+ gap: 10px;
+}
+
+.case-listing-head,
+.case-evidence-head,
+.case-activity-head {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.case-listing-head > :first-child,
+.case-evidence-head > :first-child,
+.case-activity-head > :first-child {
+ min-width: 0;
+}
+
+.case-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: fit-content;
+ max-width: 100%;
+ padding: 6px 10px;
+ border-radius: var(--radius-pill);
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ text-align: left;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ background: color-mix(in oklch, var(--accent-soft) 82%, white 18%);
+ color: var(--accent);
+}
+
+.case-pill[data-tone="risk"] {
+ background: color-mix(in oklch, var(--danger) 72%, white 28%);
+ color: var(--danger-ink);
+}
+
+.case-pill[data-tone="action"] {
+ background: color-mix(in oklch, var(--running) 72%, white 28%);
+ color: var(--running-ink);
+}
+
+.case-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.case-tag {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ padding: 6px 10px;
+ border-radius: var(--radius-pill);
+ font-size: 0.78rem;
+ text-align: left;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ background: color-mix(in oklch, white 70%, var(--bg-deep) 30%);
+ color: var(--muted);
+}
+
+.case-evidence-card p,
+.case-draft-block p,
+.case-listing-card p,
+.case-activity-item p {
+ margin: 0;
+ line-height: 1.6;
+}
+
+@media (max-width: 900px) {
+ .scene {
+ padding: 20px;
+ }
+
+ .prompt-brand {
+ gap: 12px;
+ align-items: center;
+ }
+
+ .prompt-brand-mark {
+ width: clamp(58px, 18vw, 84px);
+ }
+
+ .logo-marquee {
+ width: min(100%, 440px);
+ min-width: 0;
+ }
+
+ .panel-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .report-actions {
+ justify-content: flex-start;
+ }
+
+ .history-dropdown {
+ left: 0;
+ right: auto;
+ width: min(100vw - 40px, 360px);
+ }
+
+ .past-run-item {
+ min-height: 0;
+ }
+
+ .past-run-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .past-run-time {
+ margin-left: 0;
+ text-align: left;
+ white-space: normal;
+ }
+
+ .case-overview {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .case-section--profile,
+ .case-section--seed,
+ .case-section--listings,
+ .case-section--draft,
+ .case-section--evidence,
+ .case-section--activity {
+ grid-column: 1 / -1;
+ }
+
+ .progress-panel {
+ height: 100%;
+ max-height: 100%;
+ }
+
+ .timeline-shell {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .timeline-rail {
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(136px, 1fr);
+ align-content: start;
+ overflow-x: auto;
+ padding: 0 0 8px;
+ scrollbar-width: none;
+ }
+
+ .timeline-rail::-webkit-scrollbar {
+ display: none;
+ }
+
+ .timeline-rail::before {
+ content: "";
+ position: absolute;
+ top: auto;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ width: auto;
+ height: 1px;
+ }
+
+ .timeline-rail-step {
+ min-height: 0;
+ padding: 8px 6px 14px 0;
+ }
+
+ .timeline-rail-step::before,
+ .timeline-rail-step::after {
+ display: none;
+ }
+
+ .timeline-rail-step.is-active {
+ transform: translateX(0);
+ }
+
+ .timeline-card-header,
+ .search-log-header,
+ .analysis-log-head,
+ .candidate-card-head {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .source-stage,
+ .analysis-stage,
+ .candidate-stream,
+ .meta-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .composer-footer {
+ flex-wrap: wrap;
+ }
+
+ .ranking-item {
+ grid-template-columns: 36px minmax(0, 1fr) 32px;
+ grid-template-areas:
+ "rank main toggle"
+ "details details details";
+ }
+
+ .ranking-metadata,
+ .case-listing-head {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .ranking-case-action {
+ margin-left: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .logo-track,
+ .shell,
+ .progress-fill,
+ button,
+ .composer,
+ .timeline-track,
+ .timeline-step,
+ .timeline-rail-step,
+ .timeline-rail-number,
+ .timeline-number {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/TinyDetective/frontend/tinydetective.png b/TinyDetective/frontend/tinydetective.png
new file mode 100644
index 000000000..1a44eb301
Binary files /dev/null and b/TinyDetective/frontend/tinydetective.png differ
diff --git a/TinyDetective/frontend/tinydetective_nofish.png b/TinyDetective/frontend/tinydetective_nofish.png
new file mode 100644
index 000000000..394d43d9c
Binary files /dev/null and b/TinyDetective/frontend/tinydetective_nofish.png differ
diff --git a/TinyDetective/models/__init__.py b/TinyDetective/models/__init__.py
new file mode 100644
index 000000000..d41edaf61
--- /dev/null
+++ b/TinyDetective/models/__init__.py
@@ -0,0 +1 @@
+"""Typed schemas used across the application."""
diff --git a/TinyDetective/models/case_schemas.py b/TinyDetective/models/case_schemas.py
new file mode 100644
index 000000000..1fd40103d
--- /dev/null
+++ b/TinyDetective/models/case_schemas.py
@@ -0,0 +1,159 @@
+"""Pydantic schemas for seller-case generation."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from enum import Enum
+from uuid import uuid4
+
+from pydantic import BaseModel, Field, HttpUrl
+
+from models.schemas import (
+ ActivityLogEntry,
+ AgentTaskState,
+ ComparisonResult,
+ SourceProduct,
+ utc_now,
+)
+
+
+class SellerCaseStatus(str, Enum):
+ queued = "queued"
+ running = "running"
+ delayed = "delayed"
+ completed = "completed"
+ failed = "failed"
+ reviewed = "reviewed"
+ exported = "exported"
+
+
+class SellerProfile(BaseModel):
+ seller_name: str | None = None
+ seller_id: str | None = None
+ seller_url: HttpUrl | str | None = None
+ marketplace: str | None = None
+ rating: float | None = None
+ rating_count: int | None = None
+ follower_count: int | None = None
+ joined_date: str | None = None
+ location: str | None = None
+ badges: list[str] = Field(default_factory=list)
+ profile_text: str | None = None
+ storefront_summary: str | None = None
+ official_store_claims: list[str] = Field(default_factory=list)
+ image_urls: list[str] = Field(default_factory=list)
+ entry_urls: list[str] = Field(default_factory=list)
+ storefront_shard_urls: list[str] = Field(default_factory=list)
+ extraction_confidence: float = 0.0
+
+
+class SellerListing(BaseModel):
+ product_url: HttpUrl | str
+ marketplace: str
+ seller_name: str | None = None
+ seller_store_url: HttpUrl | str | None = None
+ seller_id: str | None = None
+ title: str | None = None
+ price: float | None = None
+ currency: str | None = None
+ brand: str | None = None
+ color: str | None = None
+ size: str | None = None
+ material: str | None = None
+ model: str | None = None
+ sku: str | None = None
+ description: str | None = None
+ image_urls: list[str] = Field(default_factory=list)
+ discovery_entry_url: HttpUrl | str | None = None
+ discovery_shard_url: HttpUrl | str | None = None
+ discovery_source: str | None = None
+
+
+class SellerListingTriageAssessment(BaseModel):
+ product_url: HttpUrl | str
+ investigation_priority_score: float
+ suspicion_score: float
+ should_shortlist: bool
+ rationale: str
+ suspicious_signals: list[str] = Field(default_factory=list)
+
+
+class OfficialProductMatch(BaseModel):
+ product_url: HttpUrl | str
+ official_product_url: HttpUrl | str | None = None
+ official_product: SourceProduct | None = None
+ match_confidence: float = 0.0
+ rationale: str = ""
+ search_queries: list[str] = Field(default_factory=list)
+
+
+class SellerCaseEvidenceItem(BaseModel):
+ evidence_id: str = Field(default_factory=lambda: str(uuid4()))
+ type: str
+ title: str
+ note: str
+ reference_url: HttpUrl | str | None = None
+ source_value: str | float | None = None
+ candidate_value: str | float | None = None
+ confidence: float = 0.0
+ subject: str | None = None
+ supporting_signals: list[str] = Field(default_factory=list)
+
+
+class ActionRequestDraft(BaseModel):
+ case_title: str
+ summary: str
+ reasoning: str
+ suspected_violation_type: str
+ recommended_action: str
+ request_text: str
+ evidence_references: list[str] = Field(default_factory=list)
+ confidence: float = 0.0
+
+
+class SellerCaseCreateRequest(BaseModel):
+ investigation_id: str
+ source_url: HttpUrl | str
+ product_url: HttpUrl | str
+ max_listings_to_analyze: int = Field(default=8, ge=1, le=20)
+ max_shortlisted_listings: int = Field(default=6, ge=1, le=20)
+ max_storefront_shards: int = Field(default=3, ge=1, le=8)
+
+
+class SellerCaseResponse(BaseModel):
+ case_id: str
+ investigation_id: str
+ source_url: HttpUrl | str
+ product_url: HttpUrl | str
+ marketplace: str | None = None
+ seller_name: str | None = None
+ seller_store_url: HttpUrl | str | None = None
+ status: SellerCaseStatus
+ summary: str = "Queued for seller case generation."
+ source_product: SourceProduct | None = None
+ selected_listing: ComparisonResult | None = None
+ seller_profile: SellerProfile | None = None
+ discovered_listings: list[SellerListing] = Field(default_factory=list)
+ triage_assessments: list[SellerListingTriageAssessment] = Field(default_factory=list)
+ shortlisted_listing_urls: list[str] = Field(default_factory=list)
+ official_product_matches: list[OfficialProductMatch] = Field(default_factory=list)
+ suspect_listings: list[ComparisonResult] = Field(default_factory=list)
+ evidence: list[SellerCaseEvidenceItem] = Field(default_factory=list)
+ action_request_draft: ActionRequestDraft | None = None
+ raw_agent_outputs: list[AgentTaskState] = Field(default_factory=list)
+ activity_log: list[ActivityLogEntry] = Field(default_factory=list)
+ error: str | None = None
+ created_at: datetime = Field(default_factory=utc_now)
+ updated_at: datetime = Field(default_factory=utc_now)
+
+
+class SellerCaseListItem(BaseModel):
+ case_id: str
+ status: SellerCaseStatus
+ seller_name: str | None = None
+ marketplace: str | None = None
+ source_url: str
+ product_url: str
+ error: str | None = None
+ created_at: datetime
+ updated_at: datetime
diff --git a/TinyDetective/models/schemas.py b/TinyDetective/models/schemas.py
new file mode 100644
index 000000000..546339638
--- /dev/null
+++ b/TinyDetective/models/schemas.py
@@ -0,0 +1,185 @@
+"""Pydantic schemas for the counterfeit research MVP."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from enum import Enum
+from typing import Any
+from uuid import uuid4
+
+from pydantic import BaseModel, Field, HttpUrl
+
+
+def utc_now() -> datetime:
+ """Return a timezone-aware UTC timestamp."""
+ return datetime.now(timezone.utc)
+
+
+class InvestigationStatus(str, Enum):
+ queued = "queued"
+ running = "running"
+ delayed = "delayed"
+ completed = "completed"
+ failed = "failed"
+
+
+class TaskStatus(str, Enum):
+ queued = "queued"
+ running = "running"
+ delayed = "delayed"
+ completed = "completed"
+ failed = "failed"
+
+
+class SourceProduct(BaseModel):
+ source_url: HttpUrl | str
+ brand: str | None = None
+ product_name: str | None = None
+ category: str | None = None
+ subcategory: str | None = None
+ price: float | None = None
+ currency: str | None = None
+ color: str | None = None
+ size: str | None = None
+ material: str | None = None
+ model: str | None = None
+ sku: str | None = None
+ features: list[str] = Field(default_factory=list)
+ description: str | None = None
+ image_urls: list[str] = Field(default_factory=list)
+ extraction_confidence: float = 0.0
+
+
+class CandidateProduct(BaseModel):
+ product_url: HttpUrl | str
+ marketplace: str
+ discovery_queries: list[str] = Field(default_factory=list)
+ seller_name: str | None = None
+ seller_store_url: HttpUrl | str | None = None
+ seller_id: str | None = None
+ title: str | None = None
+ price: float | None = None
+ currency: str | None = None
+ brand: str | None = None
+ color: str | None = None
+ size: str | None = None
+ material: str | None = None
+ model: str | None = None
+ sku: str | None = None
+ description: str | None = None
+ image_urls: list[str] = Field(default_factory=list)
+
+
+class CandidateTriageAssessment(BaseModel):
+ source_url: HttpUrl | str
+ product_url: HttpUrl | str
+ investigation_priority_score: float
+ suspicion_score: float
+ should_shortlist: bool
+ rationale: str
+ suspicious_signals: list[str] = Field(default_factory=list)
+
+
+class ComparisonReasoningEnrichment(BaseModel):
+ source_url: HttpUrl | str
+ product_url: HttpUrl | str
+ enriched_reason: str
+ reasoning_notes: list[str] = Field(default_factory=list)
+ additional_suspicious_signals: list[str] = Field(default_factory=list)
+ risk_adjustment: float = 0.0
+ match_adjustment: float = 0.0
+
+
+class EvidenceItem(BaseModel):
+ type: str
+ field: str
+ source_value: str | float | None = None
+ candidate_value: str | float | None = None
+ confidence: float = 0.0
+ note: str
+
+
+class ComparisonResult(BaseModel):
+ source_url: HttpUrl | str
+ product_url: HttpUrl | str
+ marketplace: str
+ match_score: float
+ is_exact_match: bool
+ is_official_store: bool = False
+ official_store_confidence: float = 0.0
+ official_store_signals: list[str] = Field(default_factory=list)
+ counterfeit_risk_score: float
+ suspicious_signals: list[str] = Field(default_factory=list)
+ reason: str
+ reasoning_notes: list[str] = Field(default_factory=list)
+ reasoning_enrichment_source: str | None = None
+ comparison_basis_source_url: HttpUrl | str | None = None
+ comparison_basis_label: str | None = None
+ comparison_basis_reason: str | None = None
+ comparison_basis_confidence: float = 0.0
+ triage_priority_score: float = 0.0
+ triage_suspicion_score: float = 0.0
+ evidence: list[EvidenceItem] = Field(default_factory=list)
+ candidate_product: CandidateProduct
+
+
+class AgentTaskState(BaseModel):
+ task_id: str = Field(default_factory=lambda: str(uuid4()))
+ agent_name: str
+ status: TaskStatus = TaskStatus.queued
+ input_payload: dict[str, Any] = Field(default_factory=dict)
+ output_payload: dict[str, Any] = Field(default_factory=dict)
+ error: str | None = None
+ provider_run_id: str | None = None
+ provider_status: str | None = None
+ last_heartbeat_at: datetime | None = None
+ last_progress_at: datetime | None = None
+ started_at: datetime | None = None
+ completed_at: datetime | None = None
+
+
+class ActivityLogEntry(BaseModel):
+ timestamp: datetime = Field(default_factory=utc_now)
+ level: str = "info"
+ agent_name: str
+ message: str
+ source_url: HttpUrl | str | None = None
+ metadata: dict[str, Any] = Field(default_factory=dict)
+
+
+class InvestigationReport(BaseModel):
+ source_url: HttpUrl | str
+ extracted_source_product: SourceProduct | None = None
+ top_matches: list[ComparisonResult] = Field(default_factory=list)
+ excluded_official_store_count: int = 0
+ summary: str
+ raw_agent_outputs: list[AgentTaskState] = Field(default_factory=list)
+ error: str | None = None
+
+
+class InvestigationCreateRequest(BaseModel):
+ source_urls: list[HttpUrl | str]
+ comparison_sites: list[HttpUrl | str] = Field(default_factory=list)
+ max_candidates_per_site: int = Field(default=5, ge=1, le=10)
+ max_shortlisted_candidates: int = Field(default=6, ge=1, le=20)
+
+
+class InvestigationResponse(BaseModel):
+ investigation_id: str
+ status: InvestigationStatus
+ reports: list[InvestigationReport] = Field(default_factory=list)
+ activity_log: list[ActivityLogEntry] = Field(default_factory=list)
+ error: str | None = None
+ created_at: datetime = Field(default_factory=utc_now)
+ updated_at: datetime = Field(default_factory=utc_now)
+
+
+class InvestigationListItem(BaseModel):
+ investigation_id: str
+ status: InvestigationStatus
+ primary_source_url: str | None = None
+ primary_source_title: str | None = None
+ source_count: int = 0
+ error: str | None = None
+ created_at: datetime
+ updated_at: datetime
diff --git a/TinyDetective/pyproject.toml b/TinyDetective/pyproject.toml
new file mode 100644
index 000000000..dc5324d33
--- /dev/null
+++ b/TinyDetective/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "tinydetective"
+version = "0.1.0"
+description = "TinyDetective counterfeit research backend and tooling"
+requires-python = ">=3.11"
+dependencies = [
+ "fastapi>=0.116.0",
+ "pydantic>=2.11.0",
+ "tinyfish>=0.2.4",
+ "uvicorn>=0.35.0",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.3.0",
+]
+
+[tool.uv]
+package = false
diff --git a/TinyDetective/services/__init__.py b/TinyDetective/services/__init__.py
new file mode 100644
index 000000000..7d32a2ed2
--- /dev/null
+++ b/TinyDetective/services/__init__.py
@@ -0,0 +1 @@
+"""Application services and orchestration runtime."""
diff --git a/TinyDetective/services/investigation_orchestrator.py b/TinyDetective/services/investigation_orchestrator.py
new file mode 100644
index 000000000..374a6e826
--- /dev/null
+++ b/TinyDetective/services/investigation_orchestrator.py
@@ -0,0 +1,1171 @@
+"""Investigation orchestrator for the counterfeit research pipeline."""
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+from typing import Any
+
+from agents.candidate_discovery_agent import CandidateDiscoveryAgent
+from agents.candidate_triage_agent import CandidateTriageAgent
+from agents.evidence_agent import EvidenceAgent
+from agents.product_comparison_agent import ProductComparisonAgent
+from agents.reasoning_enrichment_agent import ReasoningEnrichmentAgent
+from agents.ranking_agent import RankingAgent
+from agents.research_summary_agent import ResearchSummaryAgent
+from agents.source_extraction_agent import SourceExtractionAgent
+from models.schemas import (
+ AgentTaskState,
+ CandidateProduct,
+ ComparisonReasoningEnrichment,
+ CandidateTriageAssessment,
+ ComparisonResult,
+ EvidenceItem,
+ InvestigationReport,
+ InvestigationResponse,
+ InvestigationStatus,
+ SourceProduct,
+ TaskStatus,
+ utc_now,
+)
+from services.investigation_store import InvestigationStore
+from services.settings import settings
+from services.tinyfish_client import TinyFishRun
+from services.tinyfish_runtime import TinyFishRuntime
+
+
+class InvestigationOrchestrator:
+ """Coordinate the multi-agent counterfeit research workflow."""
+
+ ACTIVE_TASK_STATUSES = {TaskStatus.running, TaskStatus.delayed}
+
+ def __init__(
+ self,
+ store: InvestigationStore,
+ runtime: TinyFishRuntime | None = None,
+ source_agent: SourceExtractionAgent | None = None,
+ discovery_agent: CandidateDiscoveryAgent | None = None,
+ triage_agent: CandidateTriageAgent | None = None,
+ comparison_agent: ProductComparisonAgent | None = None,
+ evidence_agent: EvidenceAgent | None = None,
+ reasoning_enrichment_agent: ReasoningEnrichmentAgent | None = None,
+ ranking_agent: RankingAgent | None = None,
+ summary_agent: ResearchSummaryAgent | None = None,
+ ) -> None:
+ self.store = store
+ self.runtime = runtime or TinyFishRuntime()
+ self.source_agent = source_agent or SourceExtractionAgent()
+ self.discovery_agent = discovery_agent or CandidateDiscoveryAgent()
+ self.triage_agent = triage_agent or CandidateTriageAgent()
+ self.comparison_agent = comparison_agent or ProductComparisonAgent()
+ self.evidence_agent = evidence_agent or EvidenceAgent()
+ self.reasoning_enrichment_agent = reasoning_enrichment_agent or ReasoningEnrichmentAgent()
+ self.ranking_agent = ranking_agent or RankingAgent()
+ self.summary_agent = summary_agent or ResearchSummaryAgent()
+
+ @staticmethod
+ def _pending_report(source_url: str) -> InvestigationReport:
+ return InvestigationReport(
+ source_url=source_url,
+ summary="Queued for investigation.",
+ )
+
+ @staticmethod
+ def _merge_reports(
+ existing_reports: list[InvestigationReport],
+ source_urls: list[str],
+ ) -> list[InvestigationReport]:
+ reports: list[InvestigationReport] = []
+ for index, source_url in enumerate(source_urls):
+ if index < len(existing_reports):
+ report = existing_reports[index]
+ report.source_url = source_url
+ else:
+ report = InvestigationOrchestrator._pending_report(source_url)
+ reports.append(report)
+ return reports
+
+ async def _save_report_progress(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ ) -> None:
+ investigation.reports[report_index] = report
+ await self.store.save(investigation)
+
+ @staticmethod
+ async def _run_with_optional_update(
+ fn: object,
+ *args: object,
+ on_update: object | None = None,
+ **kwargs: object,
+ ) -> object:
+ if on_update is not None and "on_update" in inspect.signature(fn).parameters:
+ return await fn(*args, on_update=on_update, **kwargs)
+ return await fn(*args, **kwargs)
+
+ @staticmethod
+ def _runtime_payload(run: TinyFishRun) -> dict[str, object]:
+ 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,
+ }
+
+ @staticmethod
+ def _search_summary(comparison_sites: list[str]) -> str:
+ return (
+ f"Searching {len(comparison_sites)} marketplace target"
+ f"{'' if len(comparison_sites) == 1 else 's'}."
+ )
+
+ @staticmethod
+ def _candidate_summary(candidate_count: int) -> str:
+ if candidate_count == 0:
+ return "No candidate listings found. Moving to ranking and summary."
+ return (
+ f"Triaging {candidate_count} candidate listing"
+ f"{'' if candidate_count == 1 else 's'}."
+ )
+
+ @staticmethod
+ def _triage_summary(candidate_count: int, shortlisted_count: int | None = None) -> str:
+ if shortlisted_count is None:
+ return (
+ f"Triaging {candidate_count} discovered candidate listing"
+ f"{'' if candidate_count == 1 else 's'} with OpenAI."
+ )
+ return (
+ f"Shortlisted {shortlisted_count} candidate listing"
+ f"{'' if shortlisted_count == 1 else 's'} for parallel TinyFish extraction."
+ )
+
+ @staticmethod
+ def _comparison_summary(total_candidates: int) -> str:
+ return (
+ f"Running parallel TinyFish extraction across {total_candidates} shortlisted candidate"
+ f"{'' if total_candidates == 1 else 's'}."
+ )
+
+ @staticmethod
+ def _evidence_summary(total_candidates: int) -> str:
+ return (
+ f"Collecting evidence across {total_candidates} shortlisted candidate"
+ f"{'' if total_candidates == 1 else 's'}."
+ )
+
+ @staticmethod
+ def _reasoning_enrichment_summary(total_candidates: int) -> str:
+ return (
+ f"Refining reasoning across {total_candidates} shortlisted candidate"
+ f"{'' if total_candidates == 1 else 's'} with OpenAI."
+ )
+
+ @staticmethod
+ def _find_task(
+ task_log: list[AgentTaskState],
+ agent_name: str,
+ *,
+ identifier_key: str | None = None,
+ identifier_value: str | None = None,
+ statuses: set[TaskStatus] | None = None,
+ ) -> AgentTaskState | None:
+ for task in reversed(task_log):
+ if task.agent_name != agent_name:
+ continue
+ if identifier_key is not None and task.input_payload.get(identifier_key) != identifier_value:
+ continue
+ if statuses is not None and task.status not in statuses:
+ continue
+ return task
+ return None
+
+ @staticmethod
+ def _load_source_product(
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ ) -> SourceProduct | None:
+ if report.extracted_source_product is not None:
+ return report.extracted_source_product
+ source_task = InvestigationOrchestrator._find_task(
+ task_log,
+ "source_extraction",
+ statuses={TaskStatus.completed},
+ )
+ source_payload = source_task.output_payload.get("source_product") if source_task else None
+ if source_payload is None:
+ return None
+ return SourceProduct.model_validate(source_payload)
+
+ @staticmethod
+ def _load_candidates_from_task(task: AgentTaskState) -> list[CandidateProduct]:
+ return [
+ CandidateProduct.model_validate(candidate)
+ for candidate in task.output_payload.get("candidates", [])
+ ]
+
+ @staticmethod
+ def _load_comparison_from_task(task: AgentTaskState) -> ComparisonResult:
+ return ComparisonResult.model_validate(task.output_payload["comparison"])
+
+ @staticmethod
+ def _load_evidence_from_task(task: AgentTaskState) -> list[EvidenceItem]:
+ return [
+ EvidenceItem.model_validate(item)
+ for item in task.output_payload.get("evidence", [])
+ ]
+
+ @staticmethod
+ def _load_triage_from_task(task: AgentTaskState) -> CandidateTriageAssessment:
+ return CandidateTriageAssessment.model_validate(task.output_payload["triage"])
+
+ @staticmethod
+ def _load_reasoning_enrichment_from_task(task: AgentTaskState) -> ComparisonReasoningEnrichment:
+ return ComparisonReasoningEnrichment.model_validate(task.output_payload["enrichment"])
+
+ @staticmethod
+ def _prepare_task_for_retry(
+ task: AgentTaskState,
+ *,
+ clear_provider_state: bool = True,
+ ) -> None:
+ task.status = TaskStatus.running
+ task.error = None
+ task.output_payload = {}
+ task.started_at = utc_now()
+ task.completed_at = None
+ if clear_provider_state:
+ task.provider_run_id = None
+ task.provider_status = None
+ task.last_heartbeat_at = None
+ task.last_progress_at = None
+
+ @staticmethod
+ def _report_is_complete(report: InvestigationReport) -> bool:
+ if any(task.status in InvestigationOrchestrator.ACTIVE_TASK_STATUSES for task in report.raw_agent_outputs):
+ return False
+ if report.error is not None:
+ return True
+ return (
+ InvestigationOrchestrator._find_task(
+ report.raw_agent_outputs,
+ "research_summary",
+ statuses={TaskStatus.completed},
+ )
+ is not None
+ )
+
+ async def _apply_task_update(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ task: AgentTaskState,
+ run: TinyFishRun,
+ running_summary: str,
+ delayed_summary: str | None = None,
+ ) -> None:
+ task.provider_run_id = run.run_id
+ task.provider_status = run.status
+ task.last_heartbeat_at = run.last_heartbeat_at
+ task.last_progress_at = run.last_progress_at
+ task.status = TaskStatus.delayed if run.delayed else TaskStatus.running
+ task.output_payload = {"runtime": self._runtime_payload(run)}
+
+ investigation.status = (
+ InvestigationStatus.delayed if run.delayed else InvestigationStatus.running
+ )
+ report.summary = delayed_summary if run.delayed and delayed_summary else running_summary
+ report.raw_agent_outputs = task_log
+ report.error = None
+ await self._save_report_progress(investigation, report_index, report)
+
+ async def run_investigation(self, investigation_id: str) -> None:
+ request = await self.store.get_request(investigation_id)
+ investigation = await self.store.get(investigation_id)
+ if investigation is None or investigation.status in {InvestigationStatus.completed, InvestigationStatus.failed}:
+ return
+
+ source_urls = [str(source_url) for source_url in request.source_urls]
+ investigation.status = InvestigationStatus.running
+ investigation.updated_at = utc_now()
+ investigation.error = None
+ investigation.reports = self._merge_reports(investigation.reports, source_urls)
+ await self.store.save(investigation)
+
+ try:
+ comparison_sites = [str(site) for site in request.comparison_sites] or settings.ecommerce_store_urls
+ if not comparison_sites:
+ raise ValueError(
+ "No comparison sites were provided in the request or ECOMMERCE_STORE_URLS."
+ )
+ for report_index, source_url in enumerate(source_urls):
+ report = investigation.reports[report_index]
+ if self._report_is_complete(report):
+ continue
+ report = await self._run_for_source(
+ investigation,
+ report_index,
+ source_url,
+ comparison_sites,
+ request.max_candidates_per_site,
+ request.max_shortlisted_candidates,
+ )
+ investigation.reports[report_index] = report
+ investigation.status = InvestigationStatus.completed
+ except Exception as exc: # pragma: no cover
+ investigation.status = InvestigationStatus.failed
+ investigation.error = str(exc)
+ await self.store.save(investigation)
+
+ async def _ensure_source_product(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ source_url: str,
+ search_summary: str,
+ ) -> SourceProduct:
+ source_product = self._load_source_product(report, task_log)
+ source_task = self._find_task(task_log, "source_extraction")
+ if source_task is not None and source_task.status == TaskStatus.completed and source_product is not None:
+ report.extracted_source_product = source_product
+ return source_product
+
+ should_resume = (
+ source_task is not None
+ and source_task.status in self.ACTIVE_TASK_STATUSES
+ and bool(source_task.provider_run_id)
+ )
+ if source_task is None:
+ source_task = AgentTaskState(
+ agent_name="source_extraction",
+ status=TaskStatus.running,
+ input_payload={"source_url": source_url},
+ started_at=utc_now(),
+ )
+ task_log.append(source_task)
+ elif not should_resume:
+ self._prepare_task_for_retry(source_task)
+
+ report.summary = "Extracting official product details."
+ report.raw_agent_outputs = task_log
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+
+ if should_resume:
+ source_product, source_raw_output = await self.runtime.run_agent(
+ lambda: self.source_agent.resume(
+ source_url,
+ source_task.provider_run_id or "",
+ started_at=source_task.started_at,
+ last_progress_at=source_task.last_progress_at,
+ on_update=lambda run: self._apply_task_update(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_task,
+ run,
+ "Extracting official product details.",
+ "Extracting official product details. TinyFish is still working on the source page.",
+ ),
+ )
+ )
+ else:
+ source_product, source_raw_output = await self.runtime.run_agent(
+ lambda: self._run_with_optional_update(
+ self.source_agent.run,
+ source_url,
+ on_update=lambda run: self._apply_task_update(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_task,
+ run,
+ "Extracting official product details.",
+ "Extracting official product details. TinyFish is still working on the source page.",
+ ),
+ )
+ )
+
+ source_task.status = TaskStatus.completed
+ source_task.provider_status = source_raw_output.get("tinyfish_status")
+ source_task.provider_run_id = source_raw_output.get("tinyfish_run_id")
+ source_task.output_payload = {
+ "source_product": source_product.model_dump(),
+ "runtime": source_raw_output,
+ }
+ source_task.completed_at = utc_now()
+ report.extracted_source_product = source_product
+ report.summary = search_summary
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+ return source_product
+
+ async def _ensure_candidates(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ comparison_sites: list[str],
+ max_candidates_per_site: int,
+ search_summary: str,
+ ) -> list[CandidateProduct]:
+ legacy_discovery_task = self._find_task(
+ task_log,
+ "candidate_discovery",
+ statuses={TaskStatus.completed},
+ )
+ if (
+ legacy_discovery_task is not None
+ and "comparison_sites" in legacy_discovery_task.input_payload
+ ):
+ return self._load_candidates_from_task(legacy_discovery_task)
+
+ def merge_candidates(
+ candidates_by_url: dict[str, CandidateProduct],
+ new_candidates: list[CandidateProduct],
+ ) -> None:
+ for candidate in new_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)
+ )
+
+ candidates_by_url: dict[str, CandidateProduct] = {}
+ pending_queries: list[tuple[AgentTaskState, str, str, bool]] = []
+ build_search_queries = getattr(self.discovery_agent, "build_search_queries", None)
+ if callable(build_search_queries):
+ search_queries = build_search_queries(source_product)
+ else:
+ search_queries = [
+ value
+ for value in (
+ source_product.product_name,
+ source_product.model,
+ source_product.brand,
+ str(source_product.source_url),
+ )
+ if value
+ ][:1]
+
+ for comparison_site in comparison_sites:
+ for search_query in search_queries:
+ discovery_key = f"{comparison_site}|{search_query}"
+ discovery_task = self._find_task(
+ task_log,
+ "candidate_discovery",
+ identifier_key="discovery_key",
+ identifier_value=discovery_key,
+ )
+ if discovery_task is not None and discovery_task.status == TaskStatus.completed:
+ merge_candidates(candidates_by_url, self._load_candidates_from_task(discovery_task))
+ continue
+
+ should_resume = (
+ discovery_task is not None
+ and discovery_task.status in self.ACTIVE_TASK_STATUSES
+ and bool(discovery_task.provider_run_id)
+ )
+ if discovery_task is None:
+ discovery_task = AgentTaskState(
+ agent_name="candidate_discovery",
+ status=TaskStatus.running,
+ input_payload={
+ "comparison_site": comparison_site,
+ "search_query": search_query,
+ "discovery_key": discovery_key,
+ "top_n": max_candidates_per_site,
+ },
+ started_at=utc_now(),
+ )
+ task_log.append(discovery_task)
+ elif not should_resume:
+ self._prepare_task_for_retry(discovery_task)
+
+ pending_queries.append((discovery_task, comparison_site, search_query, should_resume))
+
+ if not pending_queries:
+ return list(candidates_by_url.values())
+
+ report.raw_agent_outputs = task_log
+ report.summary = search_summary
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+
+ async def run_query(
+ discovery_task: AgentTaskState,
+ comparison_site: str,
+ search_query: str,
+ should_resume: bool,
+ ) -> tuple[AgentTaskState, str, str, list[CandidateProduct], dict[str, Any]]:
+ update_callback = lambda run: self._apply_task_update(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ discovery_task,
+ run,
+ search_summary,
+ "Searching marketplace targets. TinyFish is still actively working through the queries.",
+ )
+ if should_resume:
+ resume_for_site = self.discovery_agent.resume_for_site
+ resume_kwargs: dict[str, Any] = {
+ "search_query": search_query,
+ "top_n": max_candidates_per_site,
+ "started_at": discovery_task.started_at,
+ "last_progress_at": discovery_task.last_progress_at,
+ "on_update": update_callback,
+ }
+ resume_params = inspect.signature(resume_for_site).parameters
+ resume_kwargs = {
+ key: value for key, value in resume_kwargs.items() if key in resume_params
+ }
+ site_candidates, discovery_raw_output = await self.runtime.run_agent(
+ lambda: resume_for_site(
+ source_product,
+ comparison_site,
+ discovery_task.provider_run_id or "",
+ **resume_kwargs,
+ )
+ )
+ else:
+ run_for_site = self.discovery_agent.run_for_site
+ run_kwargs: dict[str, Any] = {
+ "search_query": search_query,
+ "top_n": max_candidates_per_site,
+ "on_update": update_callback,
+ }
+ run_params = inspect.signature(run_for_site).parameters
+ run_kwargs = {
+ key: value for key, value in run_kwargs.items() if key in run_params
+ }
+ site_candidates, discovery_raw_output = await self.runtime.run_agent(
+ lambda: run_for_site(
+ source_product,
+ comparison_site,
+ **run_kwargs,
+ )
+ )
+ return discovery_task, comparison_site, search_query, site_candidates, discovery_raw_output
+
+ query_results = await asyncio.gather(
+ *[
+ run_query(discovery_task, comparison_site, search_query, should_resume)
+ for discovery_task, comparison_site, search_query, should_resume in pending_queries
+ ]
+ )
+
+ for discovery_task, comparison_site, search_query, site_candidates, discovery_raw_output in query_results:
+ discovery_task.status = TaskStatus.completed
+ discovery_task.provider_status = discovery_raw_output.get("tinyfish_status")
+ discovery_task.provider_run_id = discovery_raw_output.get("tinyfish_run_id")
+ discovery_task.output_payload = {
+ "comparison_site": comparison_site,
+ "search_query": search_query,
+ "candidate_count": len(site_candidates),
+ "candidates": [candidate.model_dump() for candidate in site_candidates],
+ "runtime": discovery_raw_output,
+ }
+ discovery_task.completed_at = utc_now()
+ merge_candidates(candidates_by_url, site_candidates)
+
+ report.summary = self._candidate_summary(len(candidates_by_url))
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+ return list(candidates_by_url.values())
+
+ async def _ensure_triage(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ candidates: list[CandidateProduct],
+ max_shortlisted_candidates: int,
+ ) -> list[CandidateProduct]:
+ if not candidates:
+ return []
+
+ triaged_candidates: list[tuple[CandidateProduct, CandidateTriageAssessment]] = []
+ pending: list[tuple[AgentTaskState, CandidateProduct]] = []
+
+ for candidate in candidates:
+ product_url = str(candidate.product_url)
+ triage_task = self._find_task(
+ task_log,
+ "candidate_triage",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ )
+ if triage_task is not None and triage_task.status == TaskStatus.completed:
+ triaged_candidates.append((candidate, self._load_triage_from_task(triage_task)))
+ continue
+
+ if triage_task is None:
+ triage_task = AgentTaskState(
+ agent_name="candidate_triage",
+ status=TaskStatus.running,
+ input_payload={"product_url": product_url},
+ started_at=utc_now(),
+ )
+ task_log.append(triage_task)
+ else:
+ self._prepare_task_for_retry(triage_task, clear_provider_state=False)
+
+ pending.append((triage_task, candidate))
+
+ if pending:
+ report.raw_agent_outputs = task_log
+ report.summary = self._triage_summary(len(candidates))
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+
+ async def run_triage(
+ triage_task: AgentTaskState,
+ candidate: CandidateProduct,
+ ) -> tuple[AgentTaskState, CandidateProduct, CandidateTriageAssessment]:
+ assessment = await self.runtime.run_agent(
+ lambda candidate=candidate: self.triage_agent.run(source_product, candidate)
+ )
+ return triage_task, candidate, assessment
+
+ triage_results = await asyncio.gather(
+ *[run_triage(triage_task, candidate) for triage_task, candidate in pending]
+ )
+
+ for triage_task, candidate, assessment in triage_results:
+ triage_task.status = TaskStatus.completed
+ triage_task.output_payload = {
+ "triage": assessment.model_dump(),
+ "candidate": candidate.model_dump(),
+ }
+ triage_task.completed_at = utc_now()
+ triaged_candidates.append((candidate, assessment))
+
+ shortlist_limit = max(1, min(max_shortlisted_candidates, settings.openai_shortlist_limit, len(candidates)))
+ sorted_candidates = sorted(
+ triaged_candidates,
+ key=lambda item: (
+ item[1].investigation_priority_score,
+ item[1].suspicion_score,
+ ),
+ reverse=True,
+ )
+ shortlisted = [candidate for candidate, assessment in sorted_candidates if assessment.should_shortlist]
+ if not shortlisted:
+ shortlisted = [candidate for candidate, _ in sorted_candidates[:shortlist_limit]]
+ else:
+ shortlisted = shortlisted[:shortlist_limit]
+
+ report.summary = self._triage_summary(len(candidates), len(shortlisted))
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+ return shortlisted
+
+ async def _ensure_comparisons(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ candidates: list[CandidateProduct],
+ ) -> list[ComparisonResult]:
+ if not candidates:
+ return []
+
+ comparison_summary = self._comparison_summary(len(candidates))
+ comparisons_by_url: dict[str, ComparisonResult] = {}
+ pending_comparisons: list[tuple[AgentTaskState, CandidateProduct, bool]] = []
+
+ for candidate in candidates:
+ product_url = str(candidate.product_url)
+ comparison_task = self._find_task(
+ task_log,
+ "product_comparison",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ )
+ if comparison_task is not None and comparison_task.status == TaskStatus.completed:
+ comparisons_by_url[product_url] = self._load_comparison_from_task(comparison_task)
+ continue
+
+ should_resume = (
+ comparison_task is not None
+ and comparison_task.status in self.ACTIVE_TASK_STATUSES
+ and bool(comparison_task.provider_run_id)
+ )
+ if comparison_task is None:
+ comparison_task = AgentTaskState(
+ agent_name="product_comparison",
+ status=TaskStatus.running,
+ input_payload={"product_url": product_url},
+ started_at=utc_now(),
+ )
+ task_log.append(comparison_task)
+ elif not should_resume:
+ self._prepare_task_for_retry(comparison_task)
+
+ pending_comparisons.append((comparison_task, candidate, should_resume))
+
+ if pending_comparisons:
+ report.raw_agent_outputs = task_log
+ report.summary = comparison_summary
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+
+ async def run_comparison(
+ comparison_task: AgentTaskState,
+ candidate: CandidateProduct,
+ should_resume: bool,
+ ) -> tuple[AgentTaskState, CandidateProduct, ComparisonResult, dict[str, Any]]:
+ if should_resume:
+ comparison, comparison_raw_output = await self.runtime.run_agent(
+ lambda candidate=candidate: self.comparison_agent.resume(
+ source_product,
+ candidate,
+ comparison_task.provider_run_id or "",
+ started_at=comparison_task.started_at,
+ last_progress_at=comparison_task.last_progress_at,
+ on_update=lambda run: self._apply_task_update(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ comparison_task,
+ run,
+ comparison_summary,
+ "Running parallel TinyFish extraction across shortlisted candidates.",
+ ),
+ )
+ )
+ else:
+ comparison, comparison_raw_output = await self.runtime.run_agent(
+ lambda candidate=candidate: self._run_with_optional_update(
+ self.comparison_agent.run,
+ source_product,
+ candidate,
+ on_update=lambda run: self._apply_task_update(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ comparison_task,
+ run,
+ comparison_summary,
+ "Running parallel TinyFish extraction across shortlisted candidates.",
+ ),
+ )
+ )
+ return comparison_task, candidate, comparison, comparison_raw_output
+
+ comparison_results = await asyncio.gather(
+ *[
+ run_comparison(comparison_task, candidate, should_resume)
+ for comparison_task, candidate, should_resume in pending_comparisons
+ ]
+ )
+
+ for comparison_task, candidate, comparison, comparison_raw_output in comparison_results:
+ product_url = str(candidate.product_url)
+ comparison_task.status = TaskStatus.completed
+ comparison_task.provider_status = comparison_raw_output.get("tinyfish_status")
+ comparison_task.provider_run_id = comparison_raw_output.get("tinyfish_run_id")
+ comparison_task.output_payload = {
+ "comparison": comparison.model_dump(),
+ "runtime": comparison_raw_output,
+ }
+ comparison_task.completed_at = utc_now()
+ comparisons_by_url[product_url] = comparison
+
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+
+ evidence_summary = self._evidence_summary(len(candidates))
+ pending_evidence: list[tuple[AgentTaskState, ComparisonResult]] = []
+
+ for candidate in candidates:
+ product_url = str(candidate.product_url)
+ comparison = comparisons_by_url[product_url]
+ evidence_task = self._find_task(
+ task_log,
+ "evidence",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ )
+ if evidence_task is not None and evidence_task.status == TaskStatus.completed:
+ comparison.evidence = self._load_evidence_from_task(evidence_task)
+ continue
+
+ if evidence_task is None:
+ evidence_task = AgentTaskState(
+ agent_name="evidence",
+ status=TaskStatus.running,
+ input_payload={"product_url": product_url},
+ started_at=utc_now(),
+ )
+ task_log.append(evidence_task)
+ else:
+ self._prepare_task_for_retry(evidence_task, clear_provider_state=False)
+ pending_evidence.append((evidence_task, comparison))
+
+ if pending_evidence:
+ report.raw_agent_outputs = task_log
+ report.summary = evidence_summary
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+
+ async def run_evidence(
+ evidence_task: AgentTaskState,
+ comparison: ComparisonResult,
+ ) -> tuple[AgentTaskState, ComparisonResult, list[EvidenceItem]]:
+ evidence = await self.runtime.run_agent(
+ lambda comparison=comparison: self.evidence_agent.run(source_product, comparison)
+ )
+ return evidence_task, comparison, evidence
+
+ evidence_results = await asyncio.gather(
+ *[run_evidence(evidence_task, comparison) for evidence_task, comparison in pending_evidence]
+ )
+
+ for evidence_task, comparison, evidence in evidence_results:
+ evidence_task.status = TaskStatus.completed
+ evidence_task.output_payload = {"evidence": [item.model_dump() for item in evidence]}
+ evidence_task.completed_at = utc_now()
+ comparison.evidence = evidence
+
+ await self._save_report_progress(investigation, report_index, report)
+
+ return [comparisons_by_url[str(candidate.product_url)] for candidate in candidates]
+
+ async def _ensure_reasoning_enrichment(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ comparisons: list[ComparisonResult],
+ ) -> list[ComparisonResult]:
+ if not comparisons:
+ return []
+
+ comparisons_by_url = {
+ str(comparison.product_url): comparison for comparison in comparisons
+ }
+ pending: list[tuple[AgentTaskState, ComparisonResult]] = []
+
+ for comparison in comparisons:
+ product_url = str(comparison.product_url)
+ enrichment_task = self._find_task(
+ task_log,
+ "reasoning_enrichment",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ )
+ if enrichment_task is not None and enrichment_task.status == TaskStatus.completed:
+ enrichment = self._load_reasoning_enrichment_from_task(enrichment_task)
+ enriched = self.reasoning_enrichment_agent.apply(comparison, enrichment)
+ comparisons_by_url[product_url] = enriched
+ comparison_task = self._find_task(
+ task_log,
+ "product_comparison",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ statuses={TaskStatus.completed},
+ )
+ if comparison_task is not None:
+ comparison_task.output_payload["comparison"] = enriched.model_dump()
+ continue
+
+ if enrichment_task is None:
+ enrichment_task = AgentTaskState(
+ agent_name="reasoning_enrichment",
+ status=TaskStatus.running,
+ input_payload={"product_url": product_url},
+ started_at=utc_now(),
+ )
+ task_log.append(enrichment_task)
+ else:
+ self._prepare_task_for_retry(enrichment_task, clear_provider_state=False)
+ pending.append((enrichment_task, comparison))
+
+ if pending:
+ report.raw_agent_outputs = task_log
+ report.summary = self._reasoning_enrichment_summary(len(comparisons))
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+
+ async def run_enrichment(
+ enrichment_task: AgentTaskState,
+ comparison: ComparisonResult,
+ ) -> tuple[AgentTaskState, ComparisonResult, ComparisonReasoningEnrichment]:
+ enrichment = await self.runtime.run_agent(
+ lambda comparison=comparison: self.reasoning_enrichment_agent.run(
+ source_product,
+ comparison,
+ )
+ )
+ return enrichment_task, comparison, enrichment
+
+ enrichment_results = await asyncio.gather(
+ *[
+ run_enrichment(enrichment_task, comparison)
+ for enrichment_task, comparison in pending
+ ]
+ )
+
+ for enrichment_task, comparison, enrichment in enrichment_results:
+ product_url = str(comparison.product_url)
+ enrichment_task.status = TaskStatus.completed
+ enrichment_task.output_payload = {"enrichment": enrichment.model_dump()}
+ enrichment_task.completed_at = utc_now()
+ enriched = self.reasoning_enrichment_agent.apply(comparison, enrichment)
+ comparisons_by_url[product_url] = enriched
+ comparison_task = self._find_task(
+ task_log,
+ "product_comparison",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ statuses={TaskStatus.completed},
+ )
+ if comparison_task is not None:
+ comparison_task.output_payload["comparison"] = enriched.model_dump()
+
+ await self._save_report_progress(investigation, report_index, report)
+
+ return [comparisons_by_url[str(comparison.product_url)] for comparison in comparisons]
+
+ async def _ensure_ranking(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ comparisons: list[ComparisonResult],
+ ) -> list[ComparisonResult]:
+ ranking_task = self._find_task(task_log, "ranking")
+ if ranking_task is not None and ranking_task.status == TaskStatus.completed:
+ return report.top_matches
+
+ filtered_comparisons = [
+ comparison for comparison in comparisons if not comparison.is_official_store
+ ]
+ excluded_official_store_count = len(comparisons) - len(filtered_comparisons)
+
+ if ranking_task is None:
+ ranking_task = AgentTaskState(
+ agent_name="ranking",
+ status=TaskStatus.running,
+ input_payload={
+ "comparison_count": len(comparisons),
+ "excluded_official_store_count": excluded_official_store_count,
+ },
+ started_at=utc_now(),
+ )
+ task_log.append(ranking_task)
+ else:
+ self._prepare_task_for_retry(ranking_task, clear_provider_state=False)
+
+ report.raw_agent_outputs = task_log
+ report.summary = "Ranking suspicious listings."
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+ top_matches = await self.runtime.run_agent(
+ lambda: self.ranking_agent.run(filtered_comparisons)
+ )
+ ranking_task.status = TaskStatus.completed
+ ranking_task.output_payload = {
+ "ranked_product_urls": [str(item.product_url) for item in top_matches],
+ "excluded_official_store_urls": [
+ str(item.product_url) for item in comparisons if item.is_official_store
+ ],
+ }
+ ranking_task.completed_at = utc_now()
+ report.top_matches = top_matches
+ report.excluded_official_store_count = excluded_official_store_count
+ report.summary = "Writing the final investigation summary."
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+ return top_matches
+
+ async def _ensure_summary(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ report: InvestigationReport,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ top_matches: list[ComparisonResult],
+ excluded_official_store_count: int,
+ ) -> str:
+ summary_task = self._find_task(task_log, "research_summary")
+ if summary_task is not None and summary_task.status == TaskStatus.completed:
+ return report.summary
+
+ if summary_task is None:
+ summary_task = AgentTaskState(
+ agent_name="research_summary",
+ status=TaskStatus.running,
+ input_payload={"top_match_count": len(top_matches)},
+ started_at=utc_now(),
+ )
+ task_log.append(summary_task)
+ else:
+ self._prepare_task_for_retry(summary_task, clear_provider_state=False)
+
+ report.raw_agent_outputs = task_log
+ report.error = None
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+ summary_run_kwargs: dict[str, Any] = {}
+ if "excluded_official_store_count" in inspect.signature(self.summary_agent.run).parameters:
+ summary_run_kwargs["excluded_official_store_count"] = excluded_official_store_count
+ summary = await self.runtime.run_agent(
+ lambda: self.summary_agent.run(
+ source_product,
+ top_matches,
+ **summary_run_kwargs,
+ )
+ )
+ summary_task.status = TaskStatus.completed
+ summary_task.output_payload = {"summary": summary}
+ summary_task.completed_at = utc_now()
+ report.summary = summary
+ report.raw_agent_outputs = task_log
+ investigation.status = InvestigationStatus.running
+ await self._save_report_progress(investigation, report_index, report)
+ return summary
+
+ async def _run_for_source(
+ self,
+ investigation: InvestigationResponse,
+ report_index: int,
+ source_url: str,
+ comparison_sites: list[str],
+ max_candidates_per_site: int,
+ max_shortlisted_candidates: int,
+ ) -> InvestigationReport:
+ report = investigation.reports[report_index]
+ task_log = report.raw_agent_outputs
+ source_product = self._load_source_product(report, task_log)
+ try:
+ search_summary = self._search_summary(comparison_sites)
+ source_product = await self._ensure_source_product(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_url,
+ search_summary,
+ )
+ candidates = await self._ensure_candidates(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_product,
+ comparison_sites,
+ max_candidates_per_site,
+ search_summary,
+ )
+ shortlisted_candidates = await self._ensure_triage(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_product,
+ candidates,
+ max_shortlisted_candidates,
+ )
+ comparisons = await self._ensure_comparisons(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_product,
+ shortlisted_candidates,
+ )
+ comparisons = await self._ensure_reasoning_enrichment(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_product,
+ comparisons,
+ )
+ top_matches = await self._ensure_ranking(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ comparisons,
+ )
+ await self._ensure_summary(
+ investigation,
+ report_index,
+ report,
+ task_log,
+ source_product,
+ top_matches,
+ report.excluded_official_store_count,
+ )
+ return report
+ except Exception as exc:
+ active_task = next(
+ (
+ task
+ for task in reversed(task_log)
+ if task.status in self.ACTIVE_TASK_STATUSES
+ ),
+ None,
+ )
+ if active_task is not None:
+ active_task.status = TaskStatus.failed
+ active_task.error = str(exc)
+ active_task.completed_at = utc_now()
+ else:
+ task_log.append(
+ AgentTaskState(
+ agent_name="research_summary",
+ status=TaskStatus.failed,
+ input_payload={"source_url": source_url},
+ error=str(exc),
+ started_at=utc_now(),
+ completed_at=utc_now(),
+ )
+ )
+ summary = await self.summary_agent.run(source_product, [], error=str(exc))
+ report.extracted_source_product = source_product
+ report.top_matches = []
+ report.summary = summary
+ report.raw_agent_outputs = task_log
+ report.error = str(exc)
+ await self._save_report_progress(investigation, report_index, report)
+ return report
+
diff --git a/TinyDetective/services/investigation_store.py b/TinyDetective/services/investigation_store.py
new file mode 100644
index 000000000..8b0de8cb6
--- /dev/null
+++ b/TinyDetective/services/investigation_store.py
@@ -0,0 +1,543 @@
+"""SQLite-backed investigation persistence."""
+
+from __future__ import annotations
+
+import asyncio
+import sqlite3
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+from uuid import uuid4
+
+from models.case_schemas import (
+ SellerCaseCreateRequest,
+ SellerCaseListItem,
+ SellerCaseResponse,
+ SellerCaseStatus,
+)
+from models.schemas import (
+ ActivityLogEntry,
+ InvestigationCreateRequest,
+ InvestigationListItem,
+ InvestigationResponse,
+ InvestigationStatus,
+ utc_now,
+)
+from services.settings import settings
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+
+
+def normalize_source_url(value: str) -> str:
+ """Normalize source URLs for saved-run replay matching."""
+ raw_value = str(value).strip()
+ if not raw_value:
+ return raw_value
+ try:
+ parsed = urlsplit(raw_value)
+ except ValueError:
+ return raw_value.rstrip("/")
+
+ normalized_path = parsed.path.rstrip("/")
+ if normalized_path == "":
+ normalized_path = ""
+ normalized = parsed._replace(path=normalized_path)
+ return urlunsplit(normalized)
+
+
+class InvestigationStore:
+ """Persist investigation state in SQLite."""
+
+ def __init__(self, database_path: str | Path | None = None) -> None:
+ raw_database_path = str(database_path or settings.investigation_store_path)
+ if raw_database_path == ":memory:":
+ self._database_path: Path | None = None
+ self._database_target = raw_database_path
+ else:
+ resolved_path = Path(raw_database_path).expanduser()
+ if not resolved_path.is_absolute():
+ resolved_path = PROJECT_ROOT / resolved_path
+ self._database_path = resolved_path
+ self._database_target = str(resolved_path)
+ self._lock = asyncio.Lock()
+ self._initialize_database()
+
+ def _connect(self) -> sqlite3.Connection:
+ connection = sqlite3.connect(self._database_target)
+ connection.row_factory = sqlite3.Row
+ return connection
+
+ def _initialize_database(self) -> None:
+ if self._database_path is not None:
+ self._database_path.parent.mkdir(parents=True, exist_ok=True)
+ with self._connect() as connection:
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS investigations (
+ investigation_id TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ request_json TEXT NOT NULL,
+ response_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ connection.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_investigations_updated_at
+ ON investigations(updated_at DESC)
+ """
+ )
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS seller_cases (
+ case_id TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ request_json TEXT NOT NULL,
+ response_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ connection.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_seller_cases_updated_at
+ ON seller_cases(updated_at DESC)
+ """
+ )
+
+ def _create_sync(self, payload: InvestigationCreateRequest) -> InvestigationResponse:
+ investigation_id = str(uuid4())
+ item = InvestigationResponse(
+ investigation_id=investigation_id,
+ status=InvestigationStatus.queued,
+ )
+ with self._connect() as connection:
+ connection.execute(
+ """
+ INSERT INTO investigations (
+ investigation_id,
+ status,
+ request_json,
+ response_json,
+ created_at,
+ updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ investigation_id,
+ item.status.value,
+ payload.model_dump_json(),
+ item.model_dump_json(),
+ item.created_at.isoformat(),
+ item.updated_at.isoformat(),
+ ),
+ )
+ return item.model_copy(deep=True)
+
+ def _get_sync(self, investigation_id: str) -> InvestigationResponse | None:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT response_json FROM investigations WHERE investigation_id = ?",
+ (investigation_id,),
+ ).fetchone()
+ if row is None:
+ return None
+ return InvestigationResponse.model_validate_json(row["response_json"])
+
+ def _get_request_sync(self, investigation_id: str) -> InvestigationCreateRequest:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT request_json FROM investigations WHERE investigation_id = ?",
+ (investigation_id,),
+ ).fetchone()
+ if row is None:
+ raise KeyError(investigation_id)
+ return InvestigationCreateRequest.model_validate_json(row["request_json"])
+
+ def _save_sync(self, item: InvestigationResponse) -> None:
+ existing = self._get_sync(item.investigation_id)
+ if existing is not None and len(existing.activity_log) > len(item.activity_log):
+ item.activity_log = existing.activity_log
+ with self._connect() as connection:
+ updated_at = item.updated_at.isoformat()
+ cursor = connection.execute(
+ """
+ UPDATE investigations
+ SET status = ?, response_json = ?, updated_at = ?
+ WHERE investigation_id = ?
+ """,
+ (
+ item.status.value,
+ item.model_dump_json(),
+ updated_at,
+ item.investigation_id,
+ ),
+ )
+ if cursor.rowcount == 0:
+ raise KeyError(item.investigation_id)
+
+ def _append_activity_sync(self, investigation_id: str, entry: ActivityLogEntry) -> None:
+ item = self._get_sync(investigation_id)
+ if item is None:
+ return
+ item.activity_log.append(entry)
+ item.updated_at = utc_now()
+ self._save_sync(item)
+
+ def _create_case_sync(self, payload: SellerCaseCreateRequest) -> SellerCaseResponse:
+ case_id = str(uuid4())
+ item = SellerCaseResponse(
+ case_id=case_id,
+ investigation_id=payload.investigation_id,
+ source_url=str(payload.source_url),
+ product_url=str(payload.product_url),
+ status=SellerCaseStatus.queued,
+ )
+ with self._connect() as connection:
+ connection.execute(
+ """
+ INSERT INTO seller_cases (
+ case_id,
+ status,
+ request_json,
+ response_json,
+ created_at,
+ updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ case_id,
+ item.status.value,
+ payload.model_dump_json(),
+ item.model_dump_json(),
+ item.created_at.isoformat(),
+ item.updated_at.isoformat(),
+ ),
+ )
+ return item.model_copy(deep=True)
+
+ def _get_case_sync(self, case_id: str) -> SellerCaseResponse | None:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT response_json FROM seller_cases WHERE case_id = ?",
+ (case_id,),
+ ).fetchone()
+ if row is None:
+ return None
+ return SellerCaseResponse.model_validate_json(row["response_json"])
+
+ def _get_case_request_sync(self, case_id: str) -> SellerCaseCreateRequest:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT request_json FROM seller_cases WHERE case_id = ?",
+ (case_id,),
+ ).fetchone()
+ if row is None:
+ raise KeyError(case_id)
+ return SellerCaseCreateRequest.model_validate_json(row["request_json"])
+
+ def _save_case_sync(self, item: SellerCaseResponse) -> None:
+ existing = self._get_case_sync(item.case_id)
+ if existing is not None and len(existing.activity_log) > len(item.activity_log):
+ item.activity_log = existing.activity_log
+ with self._connect() as connection:
+ updated_at = item.updated_at.isoformat()
+ cursor = connection.execute(
+ """
+ UPDATE seller_cases
+ SET status = ?, response_json = ?, updated_at = ?
+ WHERE case_id = ?
+ """,
+ (
+ item.status.value,
+ item.model_dump_json(),
+ updated_at,
+ item.case_id,
+ ),
+ )
+ if cursor.rowcount == 0:
+ raise KeyError(item.case_id)
+
+ def _append_case_activity_sync(self, case_id: str, entry: ActivityLogEntry) -> None:
+ item = self._get_case_sync(case_id)
+ if item is None:
+ return
+ item.activity_log.append(entry)
+ item.updated_at = utc_now()
+ self._save_case_sync(item)
+
+ def _list_active_sync(self) -> list[InvestigationResponse]:
+ active_statuses = (
+ InvestigationStatus.queued.value,
+ InvestigationStatus.running.value,
+ InvestigationStatus.delayed.value,
+ )
+ with self._connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT response_json
+ FROM investigations
+ WHERE status IN (?, ?, ?)
+ ORDER BY created_at ASC
+ """,
+ active_statuses,
+ ).fetchall()
+ return [InvestigationResponse.model_validate_json(row["response_json"]) for row in rows]
+
+ def _list_recent_sync(self, limit: int) -> list[InvestigationListItem]:
+ with self._connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT investigation_id, status, request_json, response_json, created_at, updated_at
+ FROM investigations
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+
+ items: list[InvestigationListItem] = []
+ for row in rows:
+ request_payload = InvestigationCreateRequest.model_validate_json(row["request_json"])
+ response_payload = InvestigationResponse.model_validate_json(row["response_json"])
+ source_urls = [str(source_url) for source_url in request_payload.source_urls]
+ primary_report = response_payload.reports[0] if response_payload.reports else None
+ primary_source_product = primary_report.extracted_source_product if primary_report else None
+ primary_source_title = None
+ if primary_source_product is not None:
+ primary_source_title = (
+ primary_source_product.product_name
+ or primary_source_product.model
+ or primary_source_product.brand
+ )
+ items.append(
+ InvestigationListItem(
+ investigation_id=row["investigation_id"],
+ status=InvestigationStatus(row["status"]),
+ primary_source_url=source_urls[0] if source_urls else None,
+ primary_source_title=primary_source_title,
+ source_count=len(source_urls),
+ error=response_payload.error,
+ created_at=response_payload.created_at,
+ updated_at=response_payload.updated_at,
+ )
+ )
+ return items
+
+ def _find_latest_completed_by_source_urls_sync(
+ self,
+ source_urls: list[str],
+ ) -> InvestigationResponse | None:
+ normalized_sources = [normalize_source_url(source_url) for source_url in source_urls]
+ with self._connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT request_json, response_json
+ FROM investigations
+ WHERE status = ?
+ ORDER BY updated_at DESC, created_at DESC
+ """,
+ (InvestigationStatus.completed.value,),
+ ).fetchall()
+
+ for row in rows:
+ request_payload = InvestigationCreateRequest.model_validate_json(row["request_json"])
+ normalized_request_sources = [
+ normalize_source_url(str(source_url)) for source_url in request_payload.source_urls
+ ]
+ if normalized_request_sources == normalized_sources:
+ return InvestigationResponse.model_validate_json(row["response_json"])
+ return None
+
+ def _list_active_cases_sync(self) -> list[SellerCaseResponse]:
+ active_statuses = (
+ SellerCaseStatus.queued.value,
+ SellerCaseStatus.running.value,
+ SellerCaseStatus.delayed.value,
+ )
+ with self._connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT response_json
+ FROM seller_cases
+ WHERE status IN (?, ?, ?)
+ ORDER BY created_at ASC
+ """,
+ active_statuses,
+ ).fetchall()
+ return [SellerCaseResponse.model_validate_json(row["response_json"]) for row in rows]
+
+ def _list_recent_cases_sync(self, limit: int) -> list[SellerCaseListItem]:
+ with self._connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT case_id, status, request_json, response_json
+ FROM seller_cases
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+
+ items: list[SellerCaseListItem] = []
+ for row in rows:
+ request_payload = SellerCaseCreateRequest.model_validate_json(row["request_json"])
+ response_payload = SellerCaseResponse.model_validate_json(row["response_json"])
+ items.append(
+ SellerCaseListItem(
+ case_id=row["case_id"],
+ status=SellerCaseStatus(row["status"]),
+ seller_name=response_payload.seller_name,
+ marketplace=response_payload.marketplace,
+ source_url=str(request_payload.source_url),
+ product_url=str(request_payload.product_url),
+ error=response_payload.error,
+ created_at=response_payload.created_at,
+ updated_at=response_payload.updated_at,
+ )
+ )
+ return items
+
+ def _find_latest_completed_case_by_source_and_product_url_sync(
+ self,
+ source_url: str,
+ product_url: str,
+ ) -> SellerCaseResponse | None:
+ normalized_source_url = normalize_source_url(source_url)
+ normalized_product_url = normalize_source_url(product_url)
+ with self._connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT request_json, response_json
+ FROM seller_cases
+ WHERE status = ?
+ ORDER BY updated_at DESC, created_at DESC
+ """,
+ (SellerCaseStatus.completed.value,),
+ ).fetchall()
+
+ for row in rows:
+ request_payload = SellerCaseCreateRequest.model_validate_json(row["request_json"])
+ if normalize_source_url(str(request_payload.source_url)) != normalized_source_url:
+ continue
+ if normalize_source_url(str(request_payload.product_url)) != normalized_product_url:
+ continue
+ return SellerCaseResponse.model_validate_json(row["response_json"])
+ return None
+
+ def _find_latest_completed_case_by_source_url_sync(
+ self,
+ source_url: str,
+ ) -> SellerCaseResponse | None:
+ normalized_source_url = normalize_source_url(source_url)
+ with self._connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT request_json, response_json
+ FROM seller_cases
+ WHERE status = ?
+ ORDER BY updated_at DESC, created_at DESC
+ """,
+ (SellerCaseStatus.completed.value,),
+ ).fetchall()
+
+ for row in rows:
+ request_payload = SellerCaseCreateRequest.model_validate_json(row["request_json"])
+ if normalize_source_url(str(request_payload.source_url)) != normalized_source_url:
+ continue
+ return SellerCaseResponse.model_validate_json(row["response_json"])
+ return None
+
+ async def create(self, payload: InvestigationCreateRequest) -> InvestigationResponse:
+ async with self._lock:
+ return await asyncio.to_thread(self._create_sync, payload)
+
+ async def get(self, investigation_id: str) -> InvestigationResponse | None:
+ async with self._lock:
+ return await asyncio.to_thread(self._get_sync, investigation_id)
+
+ async def get_request(self, investigation_id: str) -> InvestigationCreateRequest:
+ async with self._lock:
+ return await asyncio.to_thread(self._get_request_sync, investigation_id)
+
+ async def save(self, item: InvestigationResponse) -> None:
+ async with self._lock:
+ item.updated_at = utc_now()
+ await asyncio.to_thread(self._save_sync, item)
+
+ async def list_active(self) -> list[InvestigationResponse]:
+ async with self._lock:
+ return await asyncio.to_thread(self._list_active_sync)
+
+ async def list_recent(self, limit: int = 12) -> list[InvestigationListItem]:
+ async with self._lock:
+ return await asyncio.to_thread(self._list_recent_sync, limit)
+
+ async def find_latest_completed_by_source_urls(
+ self,
+ source_urls: list[str],
+ ) -> InvestigationResponse | None:
+ async with self._lock:
+ return await asyncio.to_thread(
+ self._find_latest_completed_by_source_urls_sync,
+ source_urls,
+ )
+
+ async def append_activity(self, investigation_id: str, entry: ActivityLogEntry) -> None:
+ async with self._lock:
+ await asyncio.to_thread(self._append_activity_sync, investigation_id, entry)
+
+ async def create_case(self, payload: SellerCaseCreateRequest) -> SellerCaseResponse:
+ async with self._lock:
+ return await asyncio.to_thread(self._create_case_sync, payload)
+
+ async def get_case(self, case_id: str) -> SellerCaseResponse | None:
+ async with self._lock:
+ return await asyncio.to_thread(self._get_case_sync, case_id)
+
+ async def get_case_request(self, case_id: str) -> SellerCaseCreateRequest:
+ async with self._lock:
+ return await asyncio.to_thread(self._get_case_request_sync, case_id)
+
+ async def save_case(self, item: SellerCaseResponse) -> None:
+ async with self._lock:
+ item.updated_at = utc_now()
+ await asyncio.to_thread(self._save_case_sync, item)
+
+ async def append_case_activity(self, case_id: str, entry: ActivityLogEntry) -> None:
+ async with self._lock:
+ await asyncio.to_thread(self._append_case_activity_sync, case_id, entry)
+
+ async def list_active_cases(self) -> list[SellerCaseResponse]:
+ async with self._lock:
+ return await asyncio.to_thread(self._list_active_cases_sync)
+
+ async def list_recent_cases(self, limit: int = 12) -> list[SellerCaseListItem]:
+ async with self._lock:
+ return await asyncio.to_thread(self._list_recent_cases_sync, limit)
+
+ async def find_latest_completed_case_by_source_and_product_url(
+ self,
+ source_url: str,
+ product_url: str,
+ ) -> SellerCaseResponse | None:
+ async with self._lock:
+ return await asyncio.to_thread(
+ self._find_latest_completed_case_by_source_and_product_url_sync,
+ source_url,
+ product_url,
+ )
+
+ async def find_latest_completed_case_by_source_url(
+ self,
+ source_url: str,
+ ) -> SellerCaseResponse | None:
+ async with self._lock:
+ return await asyncio.to_thread(
+ self._find_latest_completed_case_by_source_url_sync,
+ source_url,
+ )
diff --git a/TinyDetective/services/logging_config.py b/TinyDetective/services/logging_config.py
new file mode 100644
index 000000000..f33a79d43
--- /dev/null
+++ b/TinyDetective/services/logging_config.py
@@ -0,0 +1,33 @@
+"""Logging configuration for backend and agent activity."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+
+LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
+LOG_PATH = LOG_DIR / "tinydetective.log"
+
+
+def configure_logging() -> logging.Logger:
+ LOG_DIR.mkdir(exist_ok=True)
+ logger = logging.getLogger("tinydetective")
+ if logger.handlers:
+ return logger
+
+ logger.setLevel(logging.INFO)
+ formatter = logging.Formatter(
+ "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
+ )
+
+ file_handler = logging.FileHandler(LOG_PATH, encoding="utf-8")
+ file_handler.setFormatter(formatter)
+ logger.addHandler(file_handler)
+
+ stream_handler = logging.StreamHandler()
+ stream_handler.setFormatter(formatter)
+ logger.addHandler(stream_handler)
+
+ logger.propagate = False
+ return logger
diff --git a/TinyDetective/services/openai_client.py b/TinyDetective/services/openai_client.py
new file mode 100644
index 000000000..9d539def3
--- /dev/null
+++ b/TinyDetective/services/openai_client.py
@@ -0,0 +1,112 @@
+"""Minimal OpenAI Responses API client for structured JSON outputs."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from typing import Any
+from urllib import error, request
+
+from services.settings import settings
+
+
+class OpenAIError(RuntimeError):
+ """Raised when the OpenAI API returns an error or an unusable payload."""
+
+
+class OpenAIClient:
+ """Small raw HTTP client for structured OpenAI responses."""
+
+ def __init__(self) -> None:
+ self.base_url = settings.openai_base_url.rstrip("/")
+ self.api_key = settings.openai_api_key
+
+ async def run_json(
+ self,
+ *,
+ model: str,
+ instructions: str,
+ input_text: str,
+ schema_name: str,
+ schema: dict[str, Any],
+ max_output_tokens: int = 700,
+ ) -> dict[str, Any]:
+ if not self.api_key:
+ raise OpenAIError("OPENAI_API_KEY is not configured.")
+
+ payload = {
+ "model": model,
+ "instructions": instructions,
+ "input": input_text,
+ "max_output_tokens": max_output_tokens,
+ "text": {
+ "format": {
+ "type": "json_schema",
+ "name": schema_name,
+ "strict": True,
+ "schema": schema,
+ }
+ },
+ }
+ response = await asyncio.to_thread(
+ self._request_json,
+ "POST",
+ f"{self.base_url}/v1/responses",
+ payload,
+ )
+ return self._extract_json_object(response)
+
+ def _request_json(self, method: str, url: str, payload: dict[str, Any]) -> dict[str, Any]:
+ req = request.Request(
+ url=url,
+ data=json.dumps(payload).encode("utf-8"),
+ method=method,
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {self.api_key}",
+ },
+ )
+ try:
+ with request.urlopen(req, timeout=settings.openai_http_timeout_seconds) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except error.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="replace")
+ raise OpenAIError(f"OpenAI HTTP {exc.code}: {detail}") from exc
+ except error.URLError as exc:
+ raise OpenAIError(f"Failed to reach OpenAI: {exc.reason}") from exc
+ except TimeoutError as exc:
+ raise OpenAIError("Timed out while waiting for OpenAI to respond.") from exc
+
+ @staticmethod
+ def _extract_json_object(response: dict[str, Any]) -> dict[str, Any]:
+ direct_text = response.get("output_text")
+ if isinstance(direct_text, str) and direct_text.strip():
+ return OpenAIClient._parse_json_text(direct_text)
+
+ for output_item in response.get("output", []):
+ if not isinstance(output_item, dict):
+ continue
+ for content_item in output_item.get("content", []):
+ if not isinstance(content_item, dict):
+ continue
+ text_value = content_item.get("text")
+ if isinstance(text_value, str) and text_value.strip():
+ return OpenAIClient._parse_json_text(text_value)
+ if isinstance(text_value, dict) and isinstance(text_value.get("value"), str):
+ return OpenAIClient._parse_json_text(text_value["value"])
+ if content_item.get("type") in {"output_text", "text"} and isinstance(
+ content_item.get("value"), str
+ ):
+ return OpenAIClient._parse_json_text(content_item["value"])
+
+ raise OpenAIError(f"OpenAI did not return parseable structured JSON: {response}")
+
+ @staticmethod
+ def _parse_json_text(text: str) -> dict[str, Any]:
+ try:
+ parsed = json.loads(text)
+ except json.JSONDecodeError as exc:
+ raise OpenAIError(f"OpenAI output was not valid JSON: {text}") from exc
+ if not isinstance(parsed, dict):
+ raise OpenAIError(f"OpenAI output JSON was not an object: {parsed!r}")
+ return parsed
diff --git a/TinyDetective/services/seller_case_orchestrator.py b/TinyDetective/services/seller_case_orchestrator.py
new file mode 100644
index 000000000..189cc5120
--- /dev/null
+++ b/TinyDetective/services/seller_case_orchestrator.py
@@ -0,0 +1,1392 @@
+"""Seller-case orchestration for post-investigation enforcement workflows."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+from agents.case_draft_agent import CaseDraftAgent
+from agents.official_product_match_agent import OfficialProductMatchAgent
+from agents.seller_evidence_agent import SellerEvidenceAgent
+from agents.seller_listing_analysis_agent import SellerListingAnalysisAgent
+from agents.seller_listing_discovery_agent import SellerListingDiscoveryAgent
+from agents.seller_listing_triage_agent import SellerListingTriageAgent
+from agents.seller_profile_agent import SellerProfileAgent
+from models.case_schemas import (
+ ActionRequestDraft,
+ OfficialProductMatch,
+ SellerCaseEvidenceItem,
+ SellerCaseResponse,
+ SellerCaseStatus,
+ SellerListing,
+ SellerListingTriageAssessment,
+ SellerProfile,
+)
+from models.schemas import (
+ ActivityLogEntry,
+ AgentTaskState,
+ ComparisonResult,
+ InvestigationResponse,
+ InvestigationStatus,
+ SourceProduct,
+ TaskStatus,
+ utc_now,
+)
+from services.investigation_orchestrator import InvestigationOrchestrator
+from services.investigation_store import InvestigationStore
+from services.settings import settings
+from services.tinyfish_client import TinyFishRun
+from services.tinyfish_runtime import TinyFishRuntime
+
+
+class SellerCaseOrchestrator:
+ """Build a seller-focused case from a suspicious investigation result."""
+
+ ACTIVE_TASK_STATUSES = {TaskStatus.running, TaskStatus.delayed}
+
+ def __init__(
+ self,
+ store: InvestigationStore,
+ runtime: TinyFishRuntime | None = None,
+ seller_profile_agent: SellerProfileAgent | None = None,
+ seller_listing_discovery_agent: SellerListingDiscoveryAgent | None = None,
+ seller_listing_triage_agent: SellerListingTriageAgent | None = None,
+ official_product_match_agent: OfficialProductMatchAgent | None = None,
+ seller_listing_analysis_agent: SellerListingAnalysisAgent | None = None,
+ seller_evidence_agent: SellerEvidenceAgent | None = None,
+ case_draft_agent: CaseDraftAgent | None = None,
+ ) -> None:
+ self.store = store
+ self.runtime = runtime or TinyFishRuntime()
+ self.seller_profile_agent = seller_profile_agent or SellerProfileAgent()
+ self.seller_listing_discovery_agent = (
+ seller_listing_discovery_agent or SellerListingDiscoveryAgent()
+ )
+ self.seller_listing_triage_agent = seller_listing_triage_agent or SellerListingTriageAgent()
+ self.official_product_match_agent = official_product_match_agent or OfficialProductMatchAgent()
+ self.seller_listing_analysis_agent = (
+ seller_listing_analysis_agent or SellerListingAnalysisAgent()
+ )
+ self.seller_evidence_agent = seller_evidence_agent or SellerEvidenceAgent()
+ self.case_draft_agent = case_draft_agent or CaseDraftAgent()
+
+ async def _save_case_progress(self, seller_case: SellerCaseResponse) -> None:
+ await self.store.save_case(seller_case)
+
+ async def _log_activity(
+ self,
+ seller_case: SellerCaseResponse,
+ agent_name: str,
+ message: str,
+ metadata: dict[str, Any] | None = None,
+ ) -> None:
+ await self.store.append_case_activity(
+ seller_case.case_id,
+ ActivityLogEntry(
+ agent_name=agent_name,
+ message=message,
+ source_url=seller_case.source_url,
+ metadata=metadata or {},
+ ),
+ )
+
+ async def _apply_task_update(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ task: AgentTaskState,
+ run: TinyFishRun,
+ running_summary: str,
+ delayed_summary: str | None = None,
+ ) -> None:
+ task.provider_run_id = run.run_id
+ task.provider_status = run.status
+ task.last_heartbeat_at = run.last_heartbeat_at
+ task.last_progress_at = run.last_progress_at
+ task.status = TaskStatus.delayed if run.delayed else TaskStatus.running
+ task.output_payload = {"runtime": InvestigationOrchestrator._runtime_payload(run)}
+
+ seller_case.status = SellerCaseStatus.delayed if run.delayed else SellerCaseStatus.running
+ seller_case.summary = delayed_summary if run.delayed and delayed_summary else running_summary
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ await self._save_case_progress(seller_case)
+
+ @staticmethod
+ def _load_profile(seller_case: SellerCaseResponse, task_log: list[AgentTaskState]) -> SellerProfile | None:
+ if seller_case.seller_profile is not None:
+ return seller_case.seller_profile
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "seller_profile",
+ statuses={TaskStatus.completed},
+ )
+ profile_payload = task.output_payload.get("seller_profile") if task else None
+ if profile_payload is None:
+ return None
+ return SellerProfile.model_validate(profile_payload)
+
+ @staticmethod
+ def _load_discovered_listings(
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ ) -> list[SellerListing]:
+ if seller_case.discovered_listings:
+ return seller_case.discovered_listings
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "seller_listing_discovery",
+ statuses={TaskStatus.completed},
+ )
+ if task is None:
+ return []
+ return [
+ SellerListing.model_validate(item)
+ for item in task.output_payload.get("discovered_listings", [])
+ ]
+
+ @staticmethod
+ def _load_analysis(task: AgentTaskState) -> ComparisonResult:
+ return ComparisonResult.model_validate(task.output_payload["comparison"])
+
+ @staticmethod
+ def _load_case_evidence(task_log: list[AgentTaskState]) -> list[SellerCaseEvidenceItem]:
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "seller_case_evidence",
+ statuses={TaskStatus.completed},
+ )
+ if task is None:
+ return []
+ return [
+ SellerCaseEvidenceItem.model_validate(item)
+ for item in task.output_payload.get("evidence", [])
+ ]
+
+ @staticmethod
+ def _load_case_draft(task_log: list[AgentTaskState]) -> ActionRequestDraft | None:
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "case_draft",
+ statuses={TaskStatus.completed},
+ )
+ if task is None or "draft" not in task.output_payload:
+ return None
+ return ActionRequestDraft.model_validate(task.output_payload["draft"])
+
+ @staticmethod
+ def _load_triage_assessment(task: AgentTaskState) -> SellerListingTriageAssessment:
+ return SellerListingTriageAssessment.model_validate(task.output_payload["triage"])
+
+ @staticmethod
+ def _load_official_match(task: AgentTaskState) -> OfficialProductMatch:
+ return OfficialProductMatch.model_validate(task.output_payload["official_match"])
+
+ @staticmethod
+ def _resolve_source_report(
+ investigation: InvestigationResponse,
+ source_url: str,
+ ) -> tuple[SourceProduct, list[ComparisonResult]]:
+ for report in investigation.reports:
+ if str(report.source_url) != source_url:
+ continue
+ source_product = report.extracted_source_product
+ if source_product is None:
+ raise ValueError("The selected investigation report does not contain extracted source-product data.")
+
+ comparisons: list[ComparisonResult] = list(report.top_matches)
+ if not comparisons:
+ comparisons = [
+ ComparisonResult.model_validate(task.output_payload["comparison"])
+ for task in report.raw_agent_outputs
+ if task.agent_name == "product_comparison" and "comparison" in task.output_payload
+ ]
+ return source_product, comparisons
+ raise ValueError("The selected source URL was not found in the originating investigation.")
+
+ @staticmethod
+ def _resolve_selected_listing(
+ comparisons: list[ComparisonResult],
+ product_url: str,
+ ) -> ComparisonResult:
+ for comparison in comparisons:
+ if str(comparison.product_url) == product_url:
+ return comparison
+ raise ValueError("The selected listing was not found in the originating investigation results.")
+
+ @staticmethod
+ def _listing_from_comparison(comparison: ComparisonResult) -> SellerListing:
+ candidate = comparison.candidate_product
+ return SellerListing(
+ product_url=comparison.product_url,
+ marketplace=comparison.marketplace,
+ seller_name=candidate.seller_name,
+ seller_store_url=candidate.seller_store_url,
+ seller_id=candidate.seller_id,
+ title=candidate.title,
+ price=candidate.price,
+ currency=candidate.currency,
+ brand=candidate.brand,
+ color=candidate.color,
+ size=candidate.size,
+ material=candidate.material,
+ model=candidate.model,
+ sku=candidate.sku,
+ description=candidate.description,
+ image_urls=list(candidate.image_urls),
+ )
+
+ @staticmethod
+ def _merge_discovered_listings(
+ selected_listing: ComparisonResult,
+ discovered_listings: list[SellerListing],
+ ) -> list[SellerListing]:
+ listings_by_url: dict[str, SellerListing] = {
+ str(selected_listing.product_url): SellerCaseOrchestrator._listing_from_comparison(selected_listing)
+ }
+ for listing in discovered_listings:
+ listings_by_url[str(listing.product_url)] = listing
+ return list(listings_by_url.values())
+
+ @staticmethod
+ def _sort_suspect_listings(
+ selected_listing: ComparisonResult,
+ comparisons: list[ComparisonResult],
+ ) -> list[ComparisonResult]:
+ filtered = [
+ item
+ for item in comparisons
+ if item.counterfeit_risk_score >= 0.45
+ or bool(item.suspicious_signals)
+ or item.match_score >= 0.55
+ ]
+ if not any(str(item.product_url) == str(selected_listing.product_url) for item in filtered):
+ filtered.append(selected_listing)
+ deduped: dict[str, ComparisonResult] = {}
+ for item in sorted(
+ filtered,
+ key=lambda result: (
+ result.counterfeit_risk_score,
+ result.match_score,
+ 1 if result.is_exact_match else 0,
+ ),
+ reverse=True,
+ ):
+ deduped.setdefault(str(item.product_url), item)
+ return list(deduped.values())
+
+ @staticmethod
+ def _unique_urls(*values: str | None) -> list[str]:
+ seen: set[str] = set()
+ urls: list[str] = []
+ for value in values:
+ if not value:
+ continue
+ normalized = str(value).strip()
+ if not normalized or normalized in seen:
+ continue
+ seen.add(normalized)
+ urls.append(normalized)
+ return urls
+
+ @staticmethod
+ def _merge_profiles(profiles: list[SellerProfile], fallback_marketplace: str) -> SellerProfile:
+ merged = SellerProfile(marketplace=fallback_marketplace)
+ entry_urls: list[str] = []
+ shard_urls: list[str] = []
+ badges: list[str] = []
+ official_claims: list[str] = []
+ image_urls: list[str] = []
+
+ for profile in profiles:
+ if not merged.seller_name and profile.seller_name:
+ merged.seller_name = profile.seller_name
+ if not merged.seller_id and profile.seller_id:
+ merged.seller_id = profile.seller_id
+ if not merged.seller_url and profile.seller_url:
+ merged.seller_url = profile.seller_url
+ if not merged.marketplace and profile.marketplace:
+ merged.marketplace = profile.marketplace
+ if merged.rating is None and profile.rating is not None:
+ merged.rating = profile.rating
+ if merged.rating_count is None and profile.rating_count is not None:
+ merged.rating_count = profile.rating_count
+ if merged.follower_count is None and profile.follower_count is not None:
+ merged.follower_count = profile.follower_count
+ if not merged.joined_date and profile.joined_date:
+ merged.joined_date = profile.joined_date
+ if not merged.location and profile.location:
+ merged.location = profile.location
+ if not merged.profile_text and profile.profile_text:
+ merged.profile_text = profile.profile_text
+ if not merged.storefront_summary and profile.storefront_summary:
+ merged.storefront_summary = profile.storefront_summary
+ merged.extraction_confidence = max(merged.extraction_confidence, profile.extraction_confidence)
+ badges.extend(profile.badges)
+ official_claims.extend(profile.official_store_claims)
+ image_urls.extend(profile.image_urls)
+ entry_urls.extend(profile.entry_urls)
+ shard_urls.extend(profile.storefront_shard_urls)
+
+ merged.badges = list(dict.fromkeys(badges))
+ merged.official_store_claims = list(dict.fromkeys(official_claims))
+ merged.image_urls = list(dict.fromkeys(image_urls))
+ merged.entry_urls = list(dict.fromkeys(entry_urls))
+ merged.storefront_shard_urls = list(dict.fromkeys(shard_urls))
+ return merged
+
+ @staticmethod
+ def _build_profile_entry_urls(selected_listing: ComparisonResult) -> list[str]:
+ candidate = selected_listing.candidate_product
+ return SellerCaseOrchestrator._unique_urls(
+ str(candidate.seller_store_url) if candidate.seller_store_url else None,
+ str(selected_listing.product_url),
+ )
+
+ @staticmethod
+ def _build_storefront_shards(
+ seller_profile: SellerProfile,
+ selected_listing: ComparisonResult,
+ max_storefront_shards: int,
+ ) -> list[str]:
+ shard_urls = SellerCaseOrchestrator._unique_urls(
+ *(seller_profile.storefront_shard_urls or []),
+ *(seller_profile.entry_urls or []),
+ str(seller_profile.seller_url) if seller_profile.seller_url else None,
+ str(selected_listing.candidate_product.seller_store_url)
+ if selected_listing.candidate_product.seller_store_url
+ else None,
+ str(selected_listing.product_url),
+ )
+ return shard_urls[:max_storefront_shards]
+
+ async def run_case(self, case_id: str) -> None:
+ request = await self.store.get_case_request(case_id)
+ seller_case = await self.store.get_case(case_id)
+ if seller_case is None or seller_case.status in {
+ SellerCaseStatus.completed,
+ SellerCaseStatus.failed,
+ SellerCaseStatus.reviewed,
+ SellerCaseStatus.exported,
+ }:
+ return
+
+ seller_case.status = SellerCaseStatus.running
+ seller_case.updated_at = utc_now()
+ seller_case.error = None
+ await self._save_case_progress(seller_case)
+
+ try:
+ investigation = await self.store.get(request.investigation_id)
+ if investigation is None or investigation.status != InvestigationStatus.completed:
+ raise ValueError("The source investigation is not available or has not completed yet.")
+
+ source_product, comparisons = self._resolve_source_report(
+ investigation,
+ str(request.source_url),
+ )
+ selected_listing = self._resolve_selected_listing(comparisons, str(request.product_url))
+ seller_case.source_product = source_product
+ seller_case.selected_listing = selected_listing
+ seller_case.marketplace = selected_listing.marketplace
+ seller_case.seller_name = (
+ selected_listing.candidate_product.seller_name or seller_case.seller_name
+ )
+ seller_case.seller_store_url = (
+ selected_listing.candidate_product.seller_store_url or seller_case.seller_store_url
+ )
+ await self._save_case_progress(seller_case)
+
+ task_log = seller_case.raw_agent_outputs
+ seller_profile = await self._ensure_seller_profile(
+ seller_case,
+ task_log,
+ selected_listing,
+ )
+ discovered_listings = await self._ensure_discovered_listings(
+ seller_case,
+ task_log,
+ source_product,
+ seller_profile,
+ selected_listing,
+ request.max_listings_to_analyze,
+ request.max_storefront_shards,
+ )
+ triage_assessments, shortlisted_listings = await self._ensure_listing_triage(
+ seller_case,
+ task_log,
+ source_product,
+ seller_profile,
+ selected_listing,
+ discovered_listings,
+ request.max_shortlisted_listings,
+ )
+ official_matches = await self._ensure_official_product_matches(
+ seller_case,
+ task_log,
+ source_product,
+ shortlisted_listings,
+ )
+ suspect_listings = await self._ensure_listing_analysis(
+ seller_case,
+ task_log,
+ source_product,
+ selected_listing,
+ shortlisted_listings,
+ triage_assessments,
+ official_matches,
+ )
+ evidence = await self._ensure_case_evidence(
+ seller_case,
+ task_log,
+ source_product,
+ seller_profile,
+ selected_listing,
+ suspect_listings,
+ official_matches,
+ )
+ draft = await self._ensure_case_draft(
+ seller_case,
+ task_log,
+ source_product,
+ seller_profile,
+ selected_listing,
+ suspect_listings,
+ evidence,
+ official_matches,
+ )
+
+ seller_case.status = SellerCaseStatus.completed
+ seller_case.summary = draft.summary
+ except Exception as exc: # pragma: no cover
+ active_task = next(
+ (task for task in reversed(seller_case.raw_agent_outputs) if task.status in self.ACTIVE_TASK_STATUSES),
+ None,
+ )
+ if active_task is not None:
+ active_task.status = TaskStatus.failed
+ active_task.error = str(exc)
+ active_task.completed_at = utc_now()
+ else:
+ seller_case.raw_agent_outputs.append(
+ AgentTaskState(
+ agent_name="case_draft",
+ status=TaskStatus.failed,
+ input_payload={"product_url": str(request.product_url)},
+ error=str(exc),
+ started_at=utc_now(),
+ completed_at=utc_now(),
+ )
+ )
+ seller_case.status = SellerCaseStatus.failed
+ seller_case.error = str(exc)
+ seller_case.summary = f"Seller case failed: {exc}"
+ await self._log_activity(
+ seller_case,
+ "seller_case",
+ "Seller case failed during execution.",
+ {"error": str(exc)},
+ )
+
+ await self._save_case_progress(seller_case)
+
+ async def _ensure_seller_profile(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ selected_listing: ComparisonResult,
+ ) -> SellerProfile:
+ existing_profile = self._load_profile(seller_case, task_log)
+ if existing_profile is not None and existing_profile.entry_urls:
+ seller_case.seller_profile = existing_profile
+ return existing_profile
+
+ entry_urls = self._build_profile_entry_urls(selected_listing)
+ completed_profiles: list[SellerProfile] = []
+ pending: list[tuple[AgentTaskState, str, bool]] = []
+
+ for entry_url in entry_urls:
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "seller_profile",
+ identifier_key="entry_url",
+ identifier_value=entry_url,
+ )
+ if task is not None and task.status == TaskStatus.completed and "seller_profile" in task.output_payload:
+ completed_profiles.append(SellerProfile.model_validate(task.output_payload["seller_profile"]))
+ continue
+
+ should_resume = (
+ task is not None
+ and task.status in self.ACTIVE_TASK_STATUSES
+ and bool(task.provider_run_id)
+ )
+ if task is None:
+ task = AgentTaskState(
+ agent_name="seller_profile",
+ status=TaskStatus.running,
+ input_payload={
+ "entry_url": entry_url,
+ "product_url": str(selected_listing.product_url),
+ "seller_name": selected_listing.candidate_product.seller_name,
+ "seller_store_url": selected_listing.candidate_product.seller_store_url,
+ },
+ started_at=utc_now(),
+ )
+ task_log.append(task)
+ elif not should_resume:
+ InvestigationOrchestrator._prepare_task_for_retry(task)
+
+ pending.append((task, entry_url, should_resume))
+
+ if not pending and completed_profiles:
+ merged_profile = self._merge_profiles(completed_profiles, selected_listing.marketplace)
+ seller_case.seller_profile = merged_profile
+ return merged_profile
+
+ seller_case.summary = "Inspecting seller storefront entry points in parallel."
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ seller_case.status = SellerCaseStatus.running
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_profile",
+ "Launching parallel seller profile research.",
+ {"entry_url_count": len(entry_urls)},
+ )
+
+ async def run_profile(
+ task: AgentTaskState,
+ entry_url: str,
+ should_resume: bool,
+ ) -> tuple[AgentTaskState, SellerProfile | None, dict[str, Any] | None, Exception | None]:
+ try:
+ if should_resume:
+ profile, raw_output = await self.runtime.run_agent(
+ lambda: self.seller_profile_agent.resume(
+ entry_url,
+ selected_listing.marketplace,
+ task.provider_run_id or "",
+ seller_name=selected_listing.candidate_product.seller_name,
+ seller_url=(
+ str(selected_listing.candidate_product.seller_store_url)
+ if selected_listing.candidate_product.seller_store_url
+ else None
+ ),
+ started_at=task.started_at,
+ last_progress_at=task.last_progress_at,
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Inspecting seller storefront entry points in parallel.",
+ "Inspecting seller storefront entry points in parallel. TinyFish is still traversing the seller pages.",
+ ),
+ )
+ )
+ else:
+ profile, raw_output = await self.runtime.run_agent(
+ lambda: self.seller_profile_agent.run(
+ entry_url,
+ selected_listing.marketplace,
+ seller_name=selected_listing.candidate_product.seller_name,
+ seller_url=(
+ str(selected_listing.candidate_product.seller_store_url)
+ if selected_listing.candidate_product.seller_store_url
+ else None
+ ),
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Inspecting seller storefront entry points in parallel.",
+ "Inspecting seller storefront entry points in parallel. TinyFish is still traversing the seller pages.",
+ ),
+ )
+ )
+ return task, profile, raw_output, None
+ except Exception as exc: # pragma: no cover
+ return task, None, None, exc
+
+ profile_results = await asyncio.gather(
+ *[run_profile(task, entry_url, should_resume) for task, entry_url, should_resume in pending]
+ )
+
+ for task, profile, raw_output, error in profile_results:
+ if error is not None:
+ task.status = TaskStatus.failed
+ task.error = str(error)
+ task.completed_at = utc_now()
+ await self._log_activity(
+ seller_case,
+ "seller_profile",
+ "A seller profile entry-point task failed and was skipped.",
+ {"entry_url": task.input_payload.get("entry_url"), "error": str(error)},
+ )
+ continue
+
+ assert profile is not None and raw_output is not None
+ task.status = TaskStatus.completed
+ task.provider_status = raw_output.get("tinyfish_status")
+ task.provider_run_id = raw_output.get("tinyfish_run_id")
+ task.output_payload = {
+ "seller_profile": profile.model_dump(),
+ "runtime": raw_output,
+ }
+ task.completed_at = utc_now()
+ completed_profiles.append(profile)
+
+ if not completed_profiles:
+ raise ValueError("Seller profile extraction did not return any usable storefront data.")
+
+ merged_profile = self._merge_profiles(completed_profiles, selected_listing.marketplace)
+ merged_profile.entry_urls = self._unique_urls(
+ *(merged_profile.entry_urls or []),
+ *entry_urls,
+ str(merged_profile.seller_url) if merged_profile.seller_url else None,
+ )
+ seller_case.seller_profile = merged_profile
+ seller_case.seller_name = merged_profile.seller_name or seller_case.seller_name
+ seller_case.seller_store_url = merged_profile.seller_url or seller_case.seller_store_url
+ seller_case.summary = "Enumerating related listings from seller storefront shards."
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_profile",
+ "Seller profile extraction completed.",
+ {
+ "seller_name": merged_profile.seller_name,
+ "seller_url": str(merged_profile.seller_url or ""),
+ "entry_url_count": len(merged_profile.entry_urls),
+ "shard_url_count": len(merged_profile.storefront_shard_urls),
+ },
+ )
+ return merged_profile
+
+ async def _ensure_discovered_listings(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ seller_profile: SellerProfile,
+ selected_listing: ComparisonResult,
+ max_listings_to_analyze: int,
+ max_storefront_shards: int,
+ ) -> list[SellerListing]:
+ discovered = self._load_discovered_listings(seller_case, task_log)
+ shard_urls = self._build_storefront_shards(
+ seller_profile,
+ selected_listing,
+ max_storefront_shards,
+ )
+ completed_listings: list[SellerListing] = list(discovered)
+ pending: list[tuple[AgentTaskState, str, bool]] = []
+
+ for shard_url in shard_urls:
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "seller_listing_discovery",
+ identifier_key="shard_url",
+ identifier_value=shard_url,
+ )
+ if task is not None and task.status == TaskStatus.completed and "discovered_listings" in task.output_payload:
+ completed_listings.extend(
+ [
+ SellerListing.model_validate(item)
+ for item in task.output_payload.get("discovered_listings", [])
+ ]
+ )
+ continue
+
+ should_resume = (
+ task is not None
+ and task.status in self.ACTIVE_TASK_STATUSES
+ and bool(task.provider_run_id)
+ )
+ if task is None:
+ task = AgentTaskState(
+ agent_name="seller_listing_discovery",
+ status=TaskStatus.running,
+ input_payload={
+ "seller_url": seller_profile.seller_url,
+ "product_url": str(selected_listing.product_url),
+ "shard_url": shard_url,
+ "top_n": max_listings_to_analyze,
+ },
+ started_at=utc_now(),
+ )
+ task_log.append(task)
+ elif not should_resume:
+ InvestigationOrchestrator._prepare_task_for_retry(task)
+
+ pending.append((task, shard_url, should_resume))
+
+ if not pending and completed_listings:
+ seller_case.discovered_listings = self._merge_discovered_listings(selected_listing, completed_listings)
+ return seller_case.discovered_listings
+
+ seller_case.summary = "Enumerating related listings from seller storefront shards."
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ seller_case.status = SellerCaseStatus.running
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_listing_discovery",
+ "Launching parallel seller inventory discovery.",
+ {"shard_count": len(shard_urls)},
+ )
+
+ async def run_discovery(
+ task: AgentTaskState,
+ shard_url: str,
+ should_resume: bool,
+ ) -> tuple[AgentTaskState, list[SellerListing] | None, dict[str, Any] | None, Exception | None]:
+ try:
+ if should_resume:
+ shard_listings, raw_output = await self.runtime.run_agent(
+ lambda: self.seller_listing_discovery_agent.resume(
+ source_product,
+ seller_profile,
+ selected_listing,
+ shard_url,
+ task.provider_run_id or "",
+ top_n=max_listings_to_analyze,
+ started_at=task.started_at,
+ last_progress_at=task.last_progress_at,
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Enumerating seller storefront shards in parallel.",
+ "Enumerating seller storefront shards in parallel. TinyFish is still traversing storefront pages.",
+ ),
+ )
+ )
+ else:
+ shard_listings, raw_output = await self.runtime.run_agent(
+ lambda: self.seller_listing_discovery_agent.run(
+ source_product,
+ seller_profile,
+ selected_listing,
+ shard_url,
+ top_n=max_listings_to_analyze,
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Enumerating seller storefront shards in parallel.",
+ "Enumerating seller storefront shards in parallel. TinyFish is still traversing storefront pages.",
+ ),
+ )
+ )
+ return task, shard_listings, raw_output, None
+ except Exception as exc: # pragma: no cover
+ return task, None, None, exc
+
+ discovery_results = await asyncio.gather(
+ *[run_discovery(task, shard_url, should_resume) for task, shard_url, should_resume in pending]
+ )
+
+ for task, shard_listings, raw_output, error in discovery_results:
+ if error is not None:
+ task.status = TaskStatus.failed
+ task.error = str(error)
+ task.completed_at = utc_now()
+ await self._log_activity(
+ seller_case,
+ "seller_listing_discovery",
+ "A storefront shard discovery task failed and was skipped.",
+ {"shard_url": task.input_payload.get("shard_url"), "error": str(error)},
+ )
+ continue
+
+ assert shard_listings is not None and raw_output is not None
+ task.status = TaskStatus.completed
+ task.provider_status = raw_output.get("tinyfish_status")
+ task.provider_run_id = raw_output.get("tinyfish_run_id")
+ task.output_payload = {
+ "discovered_count": len(shard_listings),
+ "discovered_listings": [item.model_dump() for item in shard_listings],
+ "runtime": raw_output,
+ }
+ task.completed_at = utc_now()
+ completed_listings.extend(shard_listings)
+
+ merged = self._merge_discovered_listings(selected_listing, completed_listings)
+ seller_case.discovered_listings = merged
+ seller_case.summary = (
+ f"Triaging {len(merged)} seller listing{'s' if len(merged) != 1 else ''} with OpenAI."
+ )
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_listing_discovery",
+ "Seller inventory discovery completed.",
+ {"listing_count": len(merged), "shard_count": len(shard_urls)},
+ )
+ return merged
+
+ async def _ensure_listing_triage(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ seller_profile: SellerProfile,
+ selected_listing: ComparisonResult,
+ discovered_listings: list[SellerListing],
+ max_shortlisted_listings: int,
+ ) -> tuple[list[SellerListingTriageAssessment], list[SellerListing]]:
+ assessments: list[SellerListingTriageAssessment] = list(seller_case.triage_assessments)
+ pending: list[tuple[AgentTaskState, SellerListing]] = []
+
+ for listing in discovered_listings:
+ product_url = str(listing.product_url)
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "seller_listing_triage",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ )
+ if task is not None and task.status == TaskStatus.completed and "triage" in task.output_payload:
+ assessments.append(self._load_triage_assessment(task))
+ continue
+
+ if task is None:
+ task = AgentTaskState(
+ agent_name="seller_listing_triage",
+ status=TaskStatus.running,
+ input_payload={"product_url": product_url},
+ started_at=utc_now(),
+ )
+ task_log.append(task)
+ else:
+ InvestigationOrchestrator._prepare_task_for_retry(task, clear_provider_state=False)
+ pending.append((task, listing))
+
+ if pending:
+ seller_case.summary = (
+ f"Triaging {len(discovered_listings)} seller listing{'s' if len(discovered_listings) != 1 else ''} with OpenAI."
+ )
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ seller_case.status = SellerCaseStatus.running
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_listing_triage",
+ "Launching parallel OpenAI triage over seller listings.",
+ {"listing_count": len(discovered_listings)},
+ )
+
+ triage_results = await asyncio.gather(
+ *[
+ self.runtime.run_agent(
+ lambda listing=listing: self.seller_listing_triage_agent.run(
+ source_product,
+ seller_profile,
+ selected_listing,
+ listing,
+ )
+ )
+ for _, listing in pending
+ ],
+ return_exceptions=True,
+ )
+
+ for (task, listing), result in zip(pending, triage_results, strict=True):
+ if isinstance(result, Exception):
+ task.status = TaskStatus.failed
+ task.error = str(result)
+ task.completed_at = utc_now()
+ await self._log_activity(
+ seller_case,
+ "seller_listing_triage",
+ "A seller listing triage task failed and was skipped.",
+ {"product_url": str(listing.product_url), "error": str(result)},
+ )
+ continue
+
+ task.status = TaskStatus.completed
+ task.output_payload = {"triage": result.model_dump()}
+ task.completed_at = utc_now()
+ assessments.append(result)
+
+ deduped_assessments: dict[str, SellerListingTriageAssessment] = {}
+ for assessment in sorted(
+ assessments,
+ key=lambda item: (item.investigation_priority_score, item.suspicion_score),
+ reverse=True,
+ ):
+ deduped_assessments.setdefault(str(assessment.product_url), assessment)
+ triage_assessments = list(deduped_assessments.values())
+ shortlist_limit = min(max_shortlisted_listings, settings.openai_shortlist_limit)
+ assessments_by_url = {str(item.product_url): item for item in triage_assessments}
+ shortlisted = [
+ listing
+ for listing in discovered_listings
+ if assessments_by_url.get(str(listing.product_url), None)
+ and assessments_by_url[str(listing.product_url)].should_shortlist
+ ]
+ shortlisted.sort(
+ key=lambda listing: (
+ assessments_by_url[str(listing.product_url)].investigation_priority_score,
+ assessments_by_url[str(listing.product_url)].suspicion_score,
+ ),
+ reverse=True,
+ )
+ if not shortlisted and discovered_listings:
+ shortlisted = sorted(
+ discovered_listings,
+ key=lambda listing: (
+ assessments_by_url.get(str(listing.product_url), SellerListingTriageAssessment(
+ product_url=listing.product_url,
+ investigation_priority_score=0.0,
+ suspicion_score=0.0,
+ should_shortlist=False,
+ rationale="Fallback shortlist.",
+ suspicious_signals=[],
+ )).investigation_priority_score,
+ assessments_by_url.get(str(listing.product_url), SellerListingTriageAssessment(
+ product_url=listing.product_url,
+ investigation_priority_score=0.0,
+ suspicion_score=0.0,
+ should_shortlist=False,
+ rationale="Fallback shortlist.",
+ suspicious_signals=[],
+ )).suspicion_score,
+ ),
+ reverse=True,
+ )[: max(1, shortlist_limit)]
+ else:
+ shortlisted = shortlisted[: max(1, shortlist_limit)]
+
+ seller_case.triage_assessments = triage_assessments
+ seller_case.shortlisted_listing_urls = [str(item.product_url) for item in shortlisted]
+ seller_case.summary = (
+ f"Matching {len(shortlisted)} shortlisted seller listing{'s' if len(shortlisted) != 1 else ''} to official product pages."
+ )
+ await self._save_case_progress(seller_case)
+ return triage_assessments, shortlisted
+
+ async def _ensure_official_product_matches(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ shortlisted_listings: list[SellerListing],
+ ) -> list[OfficialProductMatch]:
+ matches: list[OfficialProductMatch] = list(seller_case.official_product_matches)
+ pending: list[tuple[AgentTaskState, SellerListing]] = []
+
+ for listing in shortlisted_listings:
+ product_url = str(listing.product_url)
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "official_product_match",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ )
+ if task is not None and task.status == TaskStatus.completed and "official_match" in task.output_payload:
+ matches.append(self._load_official_match(task))
+ continue
+
+ should_resume = (
+ task is not None
+ and task.status in self.ACTIVE_TASK_STATUSES
+ and bool(task.provider_run_id)
+ )
+ if task is None:
+ task = AgentTaskState(
+ agent_name="official_product_match",
+ status=TaskStatus.running,
+ input_payload={"product_url": product_url},
+ started_at=utc_now(),
+ )
+ task_log.append(task)
+ elif not should_resume:
+ InvestigationOrchestrator._prepare_task_for_retry(task)
+ pending.append((task, listing))
+
+ if pending:
+ seller_case.summary = (
+ f"Matching {len(shortlisted_listings)} shortlisted seller listing{'s' if len(shortlisted_listings) != 1 else ''} to official product pages."
+ )
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ seller_case.status = SellerCaseStatus.running
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "official_product_match",
+ "Launching parallel official-site matching for shortlisted seller listings.",
+ {"listing_count": len(shortlisted_listings)},
+ )
+
+ async def run_match(
+ task: AgentTaskState,
+ listing: SellerListing,
+ should_resume: bool,
+ ) -> tuple[AgentTaskState, OfficialProductMatch | None, dict[str, Any] | None, Exception | None]:
+ try:
+ if should_resume:
+ match, raw_output = await self.runtime.run_agent(
+ lambda: self.official_product_match_agent.resume(
+ source_product,
+ listing,
+ task.provider_run_id or "",
+ started_at=task.started_at,
+ last_progress_at=task.last_progress_at,
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Matching seller listings to official product pages in parallel.",
+ "Matching seller listings to official product pages in parallel. TinyFish is still traversing the official site.",
+ ),
+ )
+ )
+ else:
+ match, raw_output = await self.runtime.run_agent(
+ lambda: self.official_product_match_agent.run(
+ source_product,
+ listing,
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Matching seller listings to official product pages in parallel.",
+ "Matching seller listings to official product pages in parallel. TinyFish is still traversing the official site.",
+ ),
+ )
+ )
+ return task, match, raw_output, None
+ except Exception as exc: # pragma: no cover
+ return task, None, None, exc
+
+ match_results = await asyncio.gather(
+ *[run_match(task, listing, bool(task.provider_run_id)) for task, listing in pending]
+ )
+
+ for task, match, raw_output, error in match_results:
+ if error is not None:
+ task.status = TaskStatus.failed
+ task.error = str(error)
+ task.completed_at = utc_now()
+ await self._log_activity(
+ seller_case,
+ "official_product_match",
+ "An official-site matching task failed and was skipped.",
+ {"product_url": task.input_payload.get("product_url"), "error": str(error)},
+ )
+ continue
+
+ assert match is not None and raw_output is not None
+ task.status = TaskStatus.completed
+ task.provider_status = raw_output.get("discovery_runtime", {}).get("tinyfish_status")
+ task.provider_run_id = raw_output.get("discovery_runtime", {}).get("tinyfish_run_id")
+ task.output_payload = {
+ "official_match": match.model_dump(),
+ "runtime": raw_output,
+ }
+ task.completed_at = utc_now()
+ matches.append(match)
+
+ deduped_matches: dict[str, OfficialProductMatch] = {}
+ for match in sorted(matches, key=lambda item: item.match_confidence, reverse=True):
+ deduped_matches.setdefault(str(match.product_url), match)
+ official_matches = list(deduped_matches.values())
+ seller_case.official_product_matches = official_matches
+ seller_case.summary = (
+ f"Deep-analyzing {len(shortlisted_listings)} shortlisted seller listing{'s' if len(shortlisted_listings) != 1 else ''} against official references."
+ )
+ await self._save_case_progress(seller_case)
+ return official_matches
+
+ async def _ensure_listing_analysis(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ selected_listing: ComparisonResult,
+ discovered_listings: list[SellerListing],
+ triage_assessments: list[SellerListingTriageAssessment],
+ official_matches: list[OfficialProductMatch],
+ ) -> list[ComparisonResult]:
+ comparisons: list[ComparisonResult] = []
+ pending: list[tuple[AgentTaskState, SellerListing, bool]] = []
+ triage_by_url = {str(item.product_url): item for item in triage_assessments}
+ official_match_by_url = {str(item.product_url): item for item in official_matches}
+
+ for listing in discovered_listings:
+ product_url = str(listing.product_url)
+ task = InvestigationOrchestrator._find_task(
+ task_log,
+ "seller_listing_analysis",
+ identifier_key="product_url",
+ identifier_value=product_url,
+ )
+ if task is not None and task.status == TaskStatus.completed:
+ comparisons.append(self._load_analysis(task))
+ continue
+
+ should_resume = (
+ task is not None
+ and task.status in self.ACTIVE_TASK_STATUSES
+ and bool(task.provider_run_id)
+ )
+ if task is None:
+ task = AgentTaskState(
+ agent_name="seller_listing_analysis",
+ status=TaskStatus.running,
+ input_payload={"product_url": product_url},
+ started_at=utc_now(),
+ )
+ task_log.append(task)
+ elif not should_resume:
+ InvestigationOrchestrator._prepare_task_for_retry(task)
+
+ pending.append((task, listing, should_resume))
+
+ if not pending:
+ seller_case.suspect_listings = self._sort_suspect_listings(selected_listing, comparisons)
+ return seller_case.suspect_listings
+
+ seller_case.summary = (
+ f"Analyzing {len(discovered_listings)} shortlisted seller listing{'s' if len(discovered_listings) != 1 else ''} in parallel."
+ )
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ seller_case.status = SellerCaseStatus.running
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_listing_analysis",
+ "Launching parallel seller listing analysis.",
+ {"listing_count": len(discovered_listings)},
+ )
+
+ async def run_analysis(
+ task: AgentTaskState,
+ listing: SellerListing,
+ should_resume: bool,
+ ) -> tuple[AgentTaskState, SellerListing, ComparisonResult | None, dict[str, Any] | None, Exception | None]:
+ try:
+ official_match = official_match_by_url.get(str(listing.product_url))
+ basis_source_product = official_match.official_product if official_match and official_match.official_product else source_product
+ if should_resume:
+ comparison, raw_output = await self.runtime.run_agent(
+ lambda: self.seller_listing_analysis_agent.resume(
+ basis_source_product,
+ listing,
+ task.provider_run_id or "",
+ started_at=task.started_at,
+ last_progress_at=task.last_progress_at,
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Analyzing seller listings in parallel.",
+ "Analyzing seller listings in parallel. TinyFish is still inspecting individual listing pages.",
+ ),
+ )
+ )
+ else:
+ comparison, raw_output = await self.runtime.run_agent(
+ lambda: self.seller_listing_analysis_agent.run(
+ basis_source_product,
+ listing,
+ on_update=lambda run: self._apply_task_update(
+ seller_case,
+ task_log,
+ task,
+ run,
+ "Analyzing seller listings in parallel.",
+ "Analyzing seller listings in parallel. TinyFish is still inspecting individual listing pages.",
+ ),
+ )
+ )
+ if official_match is not None:
+ comparison.comparison_basis_source_url = (
+ official_match.official_product_url or source_product.source_url
+ )
+ comparison.comparison_basis_label = "official_product_match"
+ comparison.comparison_basis_reason = official_match.rationale
+ comparison.comparison_basis_confidence = official_match.match_confidence
+ else:
+ comparison.comparison_basis_source_url = source_product.source_url
+ comparison.comparison_basis_label = "seed_source_product"
+ comparison.comparison_basis_reason = "Fell back to the originally selected official product."
+ comparison.comparison_basis_confidence = 0.35
+ triage = triage_by_url.get(str(listing.product_url))
+ if triage is not None:
+ comparison.triage_priority_score = triage.investigation_priority_score
+ comparison.triage_suspicion_score = triage.suspicion_score
+ for signal in triage.suspicious_signals:
+ if signal not in comparison.suspicious_signals:
+ comparison.suspicious_signals.append(signal)
+ return task, listing, comparison, raw_output, None
+ except Exception as exc: # pragma: no cover
+ return task, listing, None, None, exc
+
+ analysis_results = await asyncio.gather(
+ *[run_analysis(task, listing, should_resume) for task, listing, should_resume in pending]
+ )
+
+ for task, listing, comparison, raw_output, error in analysis_results:
+ if error is not None:
+ task.status = TaskStatus.failed
+ task.error = str(error)
+ task.completed_at = utc_now()
+ await self._log_activity(
+ seller_case,
+ "seller_listing_analysis",
+ "A seller listing analysis task failed and was skipped.",
+ {"product_url": str(listing.product_url), "error": str(error)},
+ )
+ continue
+
+ assert comparison is not None and raw_output is not None
+ task.status = TaskStatus.completed
+ task.provider_status = raw_output.get("tinyfish_status")
+ task.provider_run_id = raw_output.get("tinyfish_run_id")
+ task.output_payload = {
+ "comparison": comparison.model_dump(),
+ "runtime": raw_output,
+ }
+ task.completed_at = utc_now()
+ comparisons.append(comparison)
+
+ seller_case.suspect_listings = self._sort_suspect_listings(selected_listing, comparisons)
+ seller_case.summary = "Synthesizing seller-level evidence."
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_listing_analysis",
+ "Seller listing analysis completed.",
+ {"suspect_listing_count": len(seller_case.suspect_listings)},
+ )
+ return seller_case.suspect_listings
+
+ async def _ensure_case_evidence(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ seller_profile: SellerProfile,
+ selected_listing: ComparisonResult,
+ suspect_listings: list[ComparisonResult],
+ official_matches: list[OfficialProductMatch],
+ ) -> list[SellerCaseEvidenceItem]:
+ evidence = self._load_case_evidence(task_log)
+ task = InvestigationOrchestrator._find_task(task_log, "seller_case_evidence")
+ if task is not None and task.status == TaskStatus.completed and evidence:
+ seller_case.evidence = evidence
+ return evidence
+
+ if task is None:
+ task = AgentTaskState(
+ agent_name="seller_case_evidence",
+ status=TaskStatus.running,
+ input_payload={"suspect_listing_count": len(suspect_listings)},
+ started_at=utc_now(),
+ )
+ task_log.append(task)
+ else:
+ InvestigationOrchestrator._prepare_task_for_retry(task, clear_provider_state=False)
+
+ seller_case.summary = "Synthesizing seller-level evidence."
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ seller_case.status = SellerCaseStatus.running
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "seller_case_evidence",
+ "Building seller-level evidence objects.",
+ {"suspect_listing_count": len(suspect_listings)},
+ )
+
+ evidence = await self.runtime.run_agent(
+ lambda: self.seller_evidence_agent.run(
+ source_product,
+ seller_profile,
+ selected_listing,
+ suspect_listings,
+ official_matches,
+ )
+ )
+ task.status = TaskStatus.completed
+ task.output_payload = {"evidence": [item.model_dump() for item in evidence]}
+ task.completed_at = utc_now()
+ seller_case.evidence = evidence
+ seller_case.summary = "Drafting the seller enforcement case."
+ await self._save_case_progress(seller_case)
+ return evidence
+
+ async def _ensure_case_draft(
+ self,
+ seller_case: SellerCaseResponse,
+ task_log: list[AgentTaskState],
+ source_product: SourceProduct,
+ seller_profile: SellerProfile,
+ selected_listing: ComparisonResult,
+ suspect_listings: list[ComparisonResult],
+ evidence: list[SellerCaseEvidenceItem],
+ official_matches: list[OfficialProductMatch],
+ ) -> ActionRequestDraft:
+ draft = self._load_case_draft(task_log)
+ task = InvestigationOrchestrator._find_task(task_log, "case_draft")
+ if task is not None and task.status == TaskStatus.completed and draft is not None:
+ seller_case.action_request_draft = draft
+ seller_case.summary = draft.summary
+ return draft
+
+ if task is None:
+ task = AgentTaskState(
+ agent_name="case_draft",
+ status=TaskStatus.running,
+ input_payload={"evidence_count": len(evidence)},
+ started_at=utc_now(),
+ )
+ task_log.append(task)
+ else:
+ InvestigationOrchestrator._prepare_task_for_retry(task, clear_provider_state=False)
+
+ seller_case.summary = "Drafting the seller enforcement case."
+ seller_case.raw_agent_outputs = task_log
+ seller_case.error = None
+ seller_case.status = SellerCaseStatus.running
+ await self._save_case_progress(seller_case)
+ await self._log_activity(
+ seller_case,
+ "case_draft",
+ "Drafting the marketplace action request.",
+ {"evidence_count": len(evidence)},
+ )
+
+ draft = await self.runtime.run_agent(
+ lambda: self.case_draft_agent.run(
+ source_product,
+ seller_profile,
+ selected_listing,
+ suspect_listings,
+ evidence,
+ official_matches,
+ )
+ )
+ task.status = TaskStatus.completed
+ task.output_payload = {"draft": draft.model_dump()}
+ task.completed_at = utc_now()
+ seller_case.action_request_draft = draft
+ seller_case.summary = draft.summary
+ await self._save_case_progress(seller_case)
+ return draft
diff --git a/TinyDetective/services/settings.py b/TinyDetective/services/settings.py
new file mode 100644
index 000000000..bb478a170
--- /dev/null
+++ b/TinyDetective/services/settings.py
@@ -0,0 +1,81 @@
+"""Application settings loaded from environment variables and .env."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+
+ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
+DEFAULT_INVESTIGATION_STORE_PATH = Path(__file__).resolve().parent.parent / "data" / "investigations.sqlite3"
+
+
+def _load_dotenv(path: Path) -> None:
+ if not path.exists():
+ return
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip("'").strip('"')
+ os.environ.setdefault(key, value)
+
+
+def _bool_env(name: str, default: bool) -> bool:
+ value = os.getenv(name)
+ if value is None:
+ return default
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _csv_env(name: str) -> list[str]:
+ raw = os.getenv(name, "")
+ return [item.strip() for item in raw.split(",") if item.strip()]
+
+
+def _float_env(name: str, default: float) -> float:
+ raw = os.getenv(name)
+ return float(raw) if raw is not None else default
+
+
+_load_dotenv(ENV_PATH)
+
+
+@dataclass(frozen=True)
+class Settings:
+ investigation_store_path: str = os.getenv("INVESTIGATION_STORE_PATH", str(DEFAULT_INVESTIGATION_STORE_PATH))
+ tinyfish_api_key: str = os.getenv("TINYFISH_API_KEY", "")
+ tinyfish_base_url: str = os.getenv("TINYFISH_BASE_URL", "https://agent.tinyfish.ai")
+ tinyfish_browser_profile: str = os.getenv("TINYFISH_BROWSER_PROFILE", "stealth")
+ tinyfish_proxy_enabled: bool = _bool_env("TINYFISH_PROXY_ENABLED", False)
+ tinyfish_proxy_country_code: str = os.getenv("TINYFISH_PROXY_COUNTRY_CODE", "SG")
+ tinyfish_poll_interval_seconds: float = _float_env("TINYFISH_POLL_INTERVAL_SECONDS", 2.0)
+ tinyfish_http_timeout_seconds: float = _float_env("TINYFISH_HTTP_TIMEOUT_SECONDS", 15.0)
+ tinyfish_run_soft_timeout_seconds: float = _float_env("TINYFISH_RUN_SOFT_TIMEOUT_SECONDS", 300.0)
+ tinyfish_run_hard_timeout_seconds: float = _float_env("TINYFISH_RUN_HARD_TIMEOUT_SECONDS", 1800.0)
+ tinyfish_run_stall_timeout_seconds: float = _float_env("TINYFISH_RUN_STALL_TIMEOUT_SECONDS", 120.0)
+ openai_api_key: str = os.getenv("OPENAI_API_KEY", "")
+ openai_base_url: str = os.getenv("OPENAI_BASE_URL", "https://api.openai.com")
+ openai_triage_model: str = os.getenv("OPENAI_TRIAGE_MODEL", "gpt-5-mini")
+ openai_reasoning_model: str = os.getenv("OPENAI_REASONING_MODEL", "gpt-5-mini")
+ openai_http_timeout_seconds: float = _float_env("OPENAI_HTTP_TIMEOUT_SECONDS", 30.0)
+ openai_shortlist_limit: int = int(os.getenv("OPENAI_SHORTLIST_LIMIT", "6"))
+ brand_landing_page_url: str = os.getenv("BRAND_LANDING_PAGE_URL", "")
+ ecommerce_store_urls: list[str] = None # type: ignore[assignment]
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "ecommerce_store_urls", _csv_env("ECOMMERCE_STORE_URLS"))
+
+ @property
+ def tinyfish_enabled(self) -> bool:
+ return bool(self.tinyfish_api_key)
+
+ @property
+ def openai_enabled(self) -> bool:
+ return bool(self.openai_api_key)
+
+
+settings = Settings()
diff --git a/TinyDetective/services/tinyfish_client.py b/TinyDetective/services/tinyfish_client.py
new file mode 100644
index 000000000..16ea95c63
--- /dev/null
+++ b/TinyDetective/services/tinyfish_client.py
@@ -0,0 +1,230 @@
+"""TinyFish SDK client."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import time
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+from tinyfish import (
+ APIConnectionError,
+ APIStatusError,
+ APITimeoutError,
+ AsyncTinyFish,
+ BrowserProfile,
+ ProxyConfig,
+ ProxyCountryCode,
+)
+
+from services.settings import settings
+
+
+class TinyFishError(RuntimeError):
+ """Raised when TinyFish returns an error or unexpected payload."""
+
+
+@dataclass
+class TinyFishRun:
+ run_id: str
+ status: str
+ result: Any = None
+ error: Any = None
+ raw: dict[str, Any] | None = None
+ elapsed_seconds: float | None = None
+ delayed: bool = False
+ last_heartbeat_at: datetime | None = None
+ last_progress_at: datetime | None = None
+
+
+TinyFishRunUpdateCallback = Callable[[TinyFishRun], Awaitable[None] | None]
+
+
+class TinyFishClient:
+ """TinyFish SDK wrapper that preserves the app's run/poll contract."""
+
+ def __init__(self) -> None:
+ self.base_url = settings.tinyfish_base_url.rstrip("/")
+ self.api_key = settings.tinyfish_api_key
+ self.browser_profile = settings.tinyfish_browser_profile
+ self._client: AsyncTinyFish | None = None
+ os.environ.setdefault("TF_API_INTEGRATION", "tinydetective")
+
+ async def run_json(
+ self,
+ url: str,
+ goal: str,
+ on_update: TinyFishRunUpdateCallback | None = None,
+ ) -> TinyFishRun:
+ run_id = await self.start_run(url, goal)
+ if on_update is not None:
+ queued_at = datetime.now(timezone.utc)
+ maybe_awaitable = on_update(
+ TinyFishRun(
+ run_id=run_id,
+ status="QUEUED",
+ elapsed_seconds=0.0,
+ last_heartbeat_at=queued_at,
+ last_progress_at=queued_at,
+ )
+ )
+ if asyncio.iscoroutine(maybe_awaitable):
+ await maybe_awaitable
+ return await self.wait_for_run(run_id, on_update=on_update)
+
+ async def start_run(self, url: str, goal: str) -> str:
+ try:
+ response = await self._sdk_client().agent.queue(
+ goal=goal,
+ url=url,
+ browser_profile=self._browser_profile(),
+ proxy_config=self._proxy_config(),
+ )
+ except APITimeoutError as exc:
+ raise TinyFishError("Timed out while waiting for TinyFish to respond.") from exc
+ except APIConnectionError as exc:
+ raise TinyFishError(f"Failed to reach TinyFish: {exc.message}") from exc
+ except APIStatusError as exc:
+ raise TinyFishError(f"TinyFish HTTP {exc.status_code}: {exc.message}") from exc
+
+ run_id = response.run_id
+ if not run_id:
+ raise TinyFishError(f"TinyFish did not return a run_id: {response}")
+ return run_id
+
+ async def wait_for_run(
+ self,
+ run_id: str,
+ on_update: TinyFishRunUpdateCallback | None = None,
+ started_at: datetime | None = None,
+ last_progress_at: datetime | None = None,
+ ) -> TinyFishRun:
+ now_utc = datetime.now(timezone.utc)
+ now_mono = time.monotonic()
+ started_mono = now_mono - self._elapsed_seconds_since(started_at, now_utc)
+ last_heartbeat_mono = now_mono
+ last_progress_mono = now_mono - self._elapsed_seconds_since(last_progress_at, now_utc)
+ heartbeat_at = now_utc
+ progress_at = last_progress_at or heartbeat_at
+ last_fingerprint: str | None = None
+
+ while True:
+ now_mono = time.monotonic()
+ elapsed_seconds = now_mono - started_mono
+ if elapsed_seconds >= settings.tinyfish_run_hard_timeout_seconds:
+ raise TinyFishError(
+ f"TinyFish run {run_id} exceeded the hard timeout of "
+ f"{settings.tinyfish_run_hard_timeout_seconds:.0f}s."
+ )
+
+ try:
+ run = await self.get_run(run_id)
+ except TinyFishError as exc:
+ if now_mono - last_heartbeat_mono >= settings.tinyfish_run_stall_timeout_seconds:
+ raise TinyFishError(
+ f"TinyFish run {run_id} stalled after "
+ f"{settings.tinyfish_run_stall_timeout_seconds:.0f}s without a successful status poll: {exc}"
+ ) from exc
+ await asyncio.sleep(settings.tinyfish_poll_interval_seconds)
+ continue
+
+ heartbeat_at = datetime.now(timezone.utc)
+ last_heartbeat_mono = time.monotonic()
+ fingerprint = self._fingerprint(run)
+ if fingerprint != last_fingerprint:
+ last_fingerprint = fingerprint
+ last_progress_mono = last_heartbeat_mono
+ progress_at = heartbeat_at
+
+ run.elapsed_seconds = last_heartbeat_mono - started_mono
+ run.delayed = run.elapsed_seconds >= settings.tinyfish_run_soft_timeout_seconds
+ run.last_heartbeat_at = heartbeat_at
+ run.last_progress_at = progress_at
+
+ if on_update is not None:
+ maybe_awaitable = on_update(run)
+ if asyncio.iscoroutine(maybe_awaitable):
+ await maybe_awaitable
+
+ status = run.status.upper()
+ if status == "COMPLETED":
+ return run
+ if status in {"FAILED", "CANCELLED"}:
+ raise TinyFishError(f"TinyFish run {run_id} ended with status {status}: {run.error}")
+ await asyncio.sleep(settings.tinyfish_poll_interval_seconds)
+
+ @staticmethod
+ def _elapsed_seconds_since(timestamp: datetime | None, now: datetime) -> float:
+ if timestamp is None:
+ return 0.0
+ return max(0.0, (now - timestamp).total_seconds())
+
+ async def get_run(self, run_id: str) -> TinyFishRun:
+ try:
+ response = await self._sdk_client().runs.get(run_id)
+ except APITimeoutError as exc:
+ raise TinyFishError("Timed out while waiting for TinyFish to respond.") from exc
+ except APIConnectionError as exc:
+ raise TinyFishError(f"Failed to reach TinyFish: {exc.message}") from exc
+ except APIStatusError as exc:
+ raise TinyFishError(f"TinyFish HTTP {exc.status_code}: {exc.message}") from exc
+
+ return TinyFishRun(
+ run_id=response.run_id or run_id,
+ status=response.status.value if hasattr(response.status, "value") else str(response.status),
+ result=response.result,
+ error=response.error.model_dump(mode="json") if response.error is not None else None,
+ raw=response.model_dump(mode="json"),
+ )
+
+ def _sdk_client(self) -> AsyncTinyFish:
+ if not self.api_key:
+ raise TinyFishError("TINYFISH_API_KEY is not configured.")
+ if self._client is None:
+ self._client = AsyncTinyFish(
+ api_key=self.api_key,
+ base_url=self.base_url,
+ timeout=settings.tinyfish_http_timeout_seconds,
+ )
+ return self._client
+
+ def _browser_profile(self) -> BrowserProfile | None:
+ value = self.browser_profile.strip().lower()
+ if not value:
+ return None
+ try:
+ return BrowserProfile(value)
+ except ValueError as exc:
+ supported = ", ".join(profile.value for profile in BrowserProfile)
+ raise TinyFishError(
+ f"Unsupported TINYFISH_BROWSER_PROFILE '{self.browser_profile}'. Expected one of: {supported}."
+ ) from exc
+
+ @staticmethod
+ def _proxy_config() -> ProxyConfig | None:
+ if not settings.tinyfish_proxy_enabled:
+ return None
+ country_code = settings.tinyfish_proxy_country_code.strip().upper()
+ if not country_code:
+ return ProxyConfig(enabled=True)
+ try:
+ return ProxyConfig(enabled=True, country_code=ProxyCountryCode(country_code))
+ except ValueError:
+ return ProxyConfig(enabled=True)
+
+ @staticmethod
+ def _fingerprint(run: TinyFishRun) -> str:
+ return json.dumps(
+ {
+ "status": run.status,
+ "result": run.result,
+ "error": run.error,
+ "raw": run.raw,
+ },
+ sort_keys=True,
+ default=str,
+ )
diff --git a/TinyDetective/services/tinyfish_runtime.py b/TinyDetective/services/tinyfish_runtime.py
new file mode 100644
index 000000000..06d44d111
--- /dev/null
+++ b/TinyDetective/services/tinyfish_runtime.py
@@ -0,0 +1,16 @@
+"""TinyFish-compatible workflow runtime abstraction."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from typing import TypeVar
+
+
+T = TypeVar("T")
+
+
+class TinyFishRuntime:
+ """Small async task runner with a TinyFish-friendly interface."""
+
+ async def run_agent(self, fn: Callable[[], Awaitable[T]]) -> T:
+ return await fn()
diff --git a/TinyDetective/tests/__init__.py b/TinyDetective/tests/__init__.py
new file mode 100644
index 000000000..01b7d8733
--- /dev/null
+++ b/TinyDetective/tests/__init__.py
@@ -0,0 +1 @@
+"""Test suite for TinyDetective."""
diff --git a/TinyDetective/tests/fixtures/sample_investigation_output.json b/TinyDetective/tests/fixtures/sample_investigation_output.json
new file mode 100644
index 000000000..b70efd7e6
--- /dev/null
+++ b/TinyDetective/tests/fixtures/sample_investigation_output.json
@@ -0,0 +1,22 @@
+{
+ "investigation_id": "sample-123",
+ "status": "completed",
+ "reports": [
+ {
+ "source_url": "https://www.casetify.com/product/impact-case-hello-kitty",
+ "summary": "Results suggest a legitimate matching listing with strong structured overlap and limited counterfeit indicators.",
+ "top_matches": [
+ {
+ "product_url": "https://shopee.sg/product/impact-case-hello-kitty-official-store-1",
+ "marketplace": "Shopee",
+ "match_score": 0.95,
+ "is_exact_match": true,
+ "counterfeit_risk_score": 0.2,
+ "suspicious_signals": [],
+ "reason": "Strong structured attribute match with limited counterfeit signals.",
+ "evidence": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/TinyDetective/tests/test_agents.py b/TinyDetective/tests/test_agents.py
new file mode 100644
index 000000000..dae58f94e
--- /dev/null
+++ b/TinyDetective/tests/test_agents.py
@@ -0,0 +1,332 @@
+"""Non-network agent tests."""
+
+from __future__ import annotations
+
+import asyncio
+
+from agents.candidate_discovery_agent import CandidateDiscoveryAgent
+from agents.candidate_triage_agent import CandidateTriageAgent
+from agents.evidence_agent import EvidenceAgent
+from agents.product_comparison_agent import ProductComparisonAgent
+from agents.reasoning_enrichment_agent import ReasoningEnrichmentAgent
+from agents.ranking_agent import RankingAgent
+from agents.research_summary_agent import ResearchSummaryAgent
+from models.schemas import (
+ CandidateProduct,
+ ComparisonReasoningEnrichment,
+ ComparisonResult,
+ SourceProduct,
+)
+from services.settings import settings
+
+
+class StubComparisonAdapter:
+ async def fetch_candidate_product(self, candidate_url: str, marketplace: str):
+ candidate = CandidateProduct(
+ product_url=candidate_url,
+ marketplace=marketplace,
+ seller_name="Discount Device Hub",
+ title="Impact Case Hello Kitty Compatible Case",
+ price=19.9,
+ currency="SGD",
+ brand="CasetifyX",
+ color="Midnight Black",
+ size="iPhone 16 Pro",
+ material="Shock-absorbing TPU",
+ model="CAS-1234",
+ sku="CAS-HELLO1-ALT",
+ description="Premium impact protection with MagSafe support. Compatible edition.",
+ image_urls=[],
+ )
+ return candidate, {"tinyfish_run_id": "stub-run", "tinyfish_status": "COMPLETED"}
+
+
+class OfficialStoreStubComparisonAdapter:
+ async def fetch_candidate_product(self, candidate_url: str, marketplace: str):
+ candidate = CandidateProduct(
+ product_url=candidate_url,
+ marketplace=marketplace,
+ seller_name="Casetify Official Store",
+ title="Impact Case Hello Kitty",
+ price=89.0,
+ currency="SGD",
+ brand="Casetify",
+ color="Midnight Black",
+ size="iPhone 16 Pro",
+ material="Shock-absorbing TPU",
+ model="CAS-1234",
+ sku="CAS-HELLO1",
+ description="Premium impact protection with MagSafe support.",
+ image_urls=[],
+ )
+ return candidate, {"tinyfish_run_id": "stub-run", "tinyfish_status": "COMPLETED"}
+
+
+class SearchCaptureAdapter:
+ def __init__(self) -> None:
+ self.calls: list[dict[str, object]] = []
+
+ async def search(
+ self,
+ source_product: SourceProduct,
+ comparison_site: str,
+ search_query: str,
+ top_n: int = 3,
+ on_update=None,
+ ):
+ del source_product, on_update
+ self.calls.append(
+ {
+ "comparison_site": comparison_site,
+ "search_query": search_query,
+ "top_n": top_n,
+ }
+ )
+ candidate = CandidateProduct(
+ product_url=f"{comparison_site.rstrip('/')}/product/{search_query.replace(' ', '-')}",
+ marketplace="Shopee",
+ discovery_queries=[search_query],
+ )
+ return [candidate], {"tinyfish_run_id": "stub-run", "tinyfish_status": "COMPLETED"}
+
+
+class StubOpenAIClient:
+ async def run_json(self, **kwargs):
+ del kwargs
+ return {
+ "enriched_reason": "OpenAI found stronger suspicious overlap than the deterministic summary captured.",
+ "reasoning_notes": ["Description overlap reinforces counterfeit concern."],
+ "additional_suspicious_signals": ["description_semantic_overlap"],
+ "risk_adjustment": 0.07,
+ "match_adjustment": 0.03,
+ }
+
+
+def test_product_comparison_agent_flags_low_priced_copy() -> None:
+ async def run() -> None:
+ source_product = SourceProduct(
+ source_url="https://brand.example/products/alpha-case",
+ brand="Casetify",
+ product_name="Impact Case Hello Kitty",
+ category="Accessories",
+ subcategory="Phone Case",
+ price=89.0,
+ currency="SGD",
+ color="Midnight Black",
+ size="iPhone 16 Pro",
+ material="Shock-absorbing TPU",
+ model="CAS-1234",
+ sku="CAS-HELLO1",
+ description="Premium impact protection with MagSafe support.",
+ )
+ candidate = CandidateProduct(product_url="https://shopee.sg/product/alpha-copy", marketplace="Shopee")
+ agent = ProductComparisonAgent(adapter=StubComparisonAdapter())
+ result, _ = await agent.run(source_product, candidate)
+ assert result.counterfeit_risk_score >= 0.6
+ assert "suspiciously_low_price" in result.suspicious_signals
+ assert "brand_mismatch" in result.suspicious_signals
+ assert "sku_mismatch" not in result.suspicious_signals
+
+ asyncio.run(run())
+
+
+def test_evidence_agent_emits_structured_differences() -> None:
+ async def run() -> None:
+ source_product = SourceProduct(
+ source_url="https://brand.example/products/alpha-case",
+ brand="Casetify",
+ product_name="Impact Case Hello Kitty",
+ price=89.0,
+ currency="SGD",
+ color="Midnight Black",
+ size="iPhone 16 Pro",
+ material="Shock-absorbing TPU",
+ model="CAS-1234",
+ sku="CAS-HELLO1",
+ description="Premium impact protection with MagSafe support.",
+ )
+ comparison_agent = ProductComparisonAgent(adapter=StubComparisonAdapter())
+ candidate = CandidateProduct(product_url="https://shopee.sg/product/alpha-copy", marketplace="Shopee")
+ comparison, _ = await comparison_agent.run(source_product, candidate)
+ evidence = await EvidenceAgent().run(source_product, comparison)
+ assert any(item.field == "price" for item in evidence)
+ assert any(item.field == "brand" for item in evidence)
+ assert not any(item.field == "sku" for item in evidence)
+
+ asyncio.run(run())
+
+
+def test_product_comparison_agent_marks_official_store_for_exclusion() -> None:
+ async def run() -> None:
+ source_product = SourceProduct(
+ source_url="https://www.casetify.com/product/alpha-case",
+ brand="Casetify",
+ product_name="Impact Case Hello Kitty",
+ price=89.0,
+ currency="SGD",
+ color="Midnight Black",
+ size="iPhone 16 Pro",
+ material="Shock-absorbing TPU",
+ model="CAS-1234",
+ sku="CAS-HELLO1",
+ description="Premium impact protection with MagSafe support.",
+ )
+ candidate = CandidateProduct(
+ product_url="https://shopee.sg/product/official-alpha",
+ marketplace="Shopee",
+ )
+ result, _ = await ProductComparisonAgent(
+ adapter=OfficialStoreStubComparisonAdapter()
+ ).run(source_product, candidate)
+ assert result.is_official_store is True
+ assert result.official_store_confidence >= 0.75
+ assert "seller_name_contains_official_store_terms" in result.official_store_signals
+
+ asyncio.run(run())
+
+
+def test_research_summary_agent_explains_official_store_exclusions() -> None:
+ async def run() -> None:
+ source_product = SourceProduct(
+ source_url="https://www.casetify.com/product/alpha-case",
+ brand="Casetify",
+ product_name="Impact Case Hello Kitty",
+ )
+ summary = await ResearchSummaryAgent().run(
+ source_product,
+ [],
+ excluded_official_store_count=2,
+ )
+ assert "official-store" in summary
+
+ asyncio.run(run())
+
+
+def test_candidate_discovery_agent_builds_semantic_brand_led_queries() -> None:
+ async def run() -> None:
+ adapter = SearchCaptureAdapter()
+ agent = CandidateDiscoveryAgent(adapter=adapter)
+ source_product = SourceProduct(
+ source_url="https://www.casetify.com/product/impact-case-hello-kitty",
+ brand="Casetify",
+ product_name="Impact Case Hello Kitty",
+ category="Accessories",
+ subcategory="Phone Case",
+ color="Midnight Black",
+ size="iPhone 16 Pro",
+ material="Shock-absorbing TPU",
+ features=["MagSafe compatible", "Impact resistance"],
+ )
+ candidates, raw_outputs = await agent.run(source_product, ["https://shopee.sg"], top_n=2)
+ queries = [call["search_query"] for call in adapter.calls]
+ assert queries
+ assert all(isinstance(query, str) for query in queries)
+ assert all(str(query).startswith("casetify ") for query in queries)
+ assert len(queries) == 5
+ assert any("phone case" in str(query) for query in queries)
+ assert len(adapter.calls) == len(queries)
+ assert len(raw_outputs) == len(queries)
+ assert len(candidates) == len(queries)
+ assert all(candidate.discovery_queries for candidate in candidates)
+
+ asyncio.run(run())
+
+
+def test_candidate_triage_agent_heuristic_shortlists_relevant_discount_listing() -> None:
+ async def run() -> None:
+ source_product = SourceProduct(
+ source_url="https://brand.example/products/alpha-case",
+ brand="Brand",
+ product_name="Alpha Case",
+ category="Accessories",
+ subcategory="Phone Case",
+ price=100.0,
+ currency="SGD",
+ )
+ candidate = CandidateProduct(
+ product_url="https://market.example/listing/alpha-case-discount",
+ marketplace="Market",
+ seller_name="Discount Hub",
+ title="Brand Alpha Case",
+ price=55.0,
+ currency="SGD",
+ brand="Brand",
+ discovery_queries=["brand alpha case"],
+ )
+ assessment = await CandidateTriageAgent().run(source_product, candidate)
+ assert assessment.should_shortlist is True
+ assert assessment.investigation_priority_score >= 0.34
+ assert assessment.suspicion_score >= 0.32
+
+ asyncio.run(run())
+
+
+def test_reasoning_enrichment_agent_applies_bounded_adjustments() -> None:
+ async def run() -> None:
+ original_api_key = settings.openai_api_key
+ object.__setattr__(settings, "openai_api_key", "test-key")
+ try:
+ source_product = SourceProduct(
+ source_url="https://brand.example/products/alpha-case",
+ brand="Brand",
+ product_name="Alpha Case",
+ description="Protective phone case",
+ )
+ comparison = ComparisonResult(
+ source_url=source_product.source_url,
+ product_url="https://market.example/listing/alpha-case",
+ marketplace="Market",
+ match_score=0.52,
+ is_exact_match=False,
+ counterfeit_risk_score=0.58,
+ suspicious_signals=["suspiciously_low_price"],
+ reason="Deterministic baseline reason.",
+ candidate_product=CandidateProduct(
+ product_url="https://market.example/listing/alpha-case",
+ marketplace="Market",
+ description="Protective phone case compatible version",
+ ),
+ )
+ agent = ReasoningEnrichmentAgent(client=StubOpenAIClient())
+ enrichment = await agent.run(source_product, comparison)
+ assert isinstance(enrichment, ComparisonReasoningEnrichment)
+ enriched = agent.apply(comparison, enrichment)
+ assert enriched.reason.startswith("OpenAI found stronger suspicious overlap")
+ assert "description_semantic_overlap" in enriched.suspicious_signals
+ assert enriched.counterfeit_risk_score == 0.65
+ assert enriched.match_score == 0.55
+ finally:
+ object.__setattr__(settings, "openai_api_key", original_api_key)
+
+ asyncio.run(run())
+
+
+def test_ranking_agent_sorts_by_risk_and_returns_five_matches() -> None:
+ async def run() -> None:
+ source_url = "https://brand.example/products/alpha-case"
+ comparisons = [
+ ComparisonResult(
+ source_url=source_url,
+ product_url=f"https://market.example/listing/{index}",
+ marketplace="Shopee",
+ match_score=0.95 - (index * 0.05),
+ is_exact_match=index == 0,
+ counterfeit_risk_score=0.1 + (index * 0.15),
+ suspicious_signals=[],
+ reason=f"Candidate {index}",
+ candidate_product=CandidateProduct(
+ product_url=f"https://market.example/listing/{index}",
+ marketplace="Shopee",
+ ),
+ )
+ for index in range(6)
+ ]
+
+ ranked = await RankingAgent().run(comparisons)
+
+ assert len(ranked) == 5
+ assert str(ranked[0].product_url).endswith("/5")
+ assert str(ranked[1].product_url).endswith("/4")
+ assert str(ranked[4].product_url).endswith("/1")
+
+ asyncio.run(run())
diff --git a/TinyDetective/tests/test_orchestrator.py b/TinyDetective/tests/test_orchestrator.py
new file mode 100644
index 000000000..eb9c5ec44
--- /dev/null
+++ b/TinyDetective/tests/test_orchestrator.py
@@ -0,0 +1,496 @@
+"""Non-network orchestrator-adjacent tests."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timezone
+
+from models.schemas import (
+ AgentTaskState,
+ CandidateProduct,
+ ComparisonReasoningEnrichment,
+ CandidateTriageAssessment,
+ ComparisonResult,
+ InvestigationCreateRequest,
+ InvestigationReport,
+ InvestigationStatus,
+ SourceProduct,
+ TaskStatus,
+)
+from services.investigation_orchestrator import InvestigationOrchestrator
+from services.investigation_store import InvestigationStore
+from services.tinyfish_client import TinyFishRun
+
+
+def test_investigation_request_defaults_comparison_sites() -> None:
+ request = InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/alpha-case"],
+ )
+ assert request.comparison_sites == []
+
+
+class BlockingSourceAgent:
+ def __init__(self) -> None:
+ self.started = asyncio.Event()
+ self.release = asyncio.Event()
+
+ async def run(self, source_url: str, on_update=None):
+ self.started.set()
+ await self.release.wait()
+ return SourceProduct(source_url=source_url, brand="Brand"), {"runtime": "stub"}
+
+
+class EmptyDiscoveryAgent:
+ async def run(
+ self,
+ source_product: SourceProduct,
+ comparison_sites: list[str],
+ top_n: int = 5,
+ on_update=None,
+ ):
+ return [], []
+
+ async def run_for_site(
+ self,
+ source_product: SourceProduct,
+ comparison_site: str,
+ top_n: int = 5,
+ on_update=None,
+ ):
+ return [], {}
+
+
+class MultiCandidateDiscoveryAgent:
+ def build_search_queries(self, source_product: SourceProduct) -> list[str]:
+ del source_product
+ return ["brand alpha case"]
+
+ async def run_for_site(
+ self,
+ source_product: SourceProduct,
+ comparison_site: str,
+ search_query: str,
+ top_n: int = 5,
+ on_update=None,
+ ):
+ del source_product, comparison_site, top_n, on_update
+ candidates = [
+ CandidateProduct(
+ product_url=f"https://market.example/{search_query.replace(' ', '-')}-{index}",
+ marketplace="Market",
+ title=f"Candidate {index}",
+ discovery_queries=[search_query],
+ )
+ for index in range(3)
+ ]
+ return candidates, {}
+
+
+class SummaryAgent:
+ async def run(
+ self,
+ source_product: SourceProduct | None,
+ top_matches: list[object],
+ excluded_official_store_count: int = 0,
+ error: str | None = None,
+ ):
+ del source_product, top_matches, excluded_official_store_count
+ return error or "Finished summary"
+
+
+class ImmediateSourceAgent:
+ async def run(self, source_url: str, on_update=None):
+ del on_update
+ return SourceProduct(source_url=source_url, brand="Brand", product_name="Alpha Case"), {}
+
+
+class StubTriageAgent:
+ async def run(self, source_product: SourceProduct, candidate: CandidateProduct):
+ del source_product
+ shortlisted = not str(candidate.product_url).endswith("-2")
+ return CandidateTriageAssessment(
+ source_url="https://brand.example/products/alpha-case",
+ product_url=str(candidate.product_url),
+ investigation_priority_score=0.8 if shortlisted else 0.1,
+ suspicion_score=0.6 if shortlisted else 0.05,
+ should_shortlist=shortlisted,
+ rationale="shortlist" if shortlisted else "skip",
+ suspicious_signals=["title_semantic_overlap"] if shortlisted else [],
+ )
+
+
+class ParallelComparisonAgent:
+ def __init__(self) -> None:
+ self.calls = 0
+ self.active = 0
+ self.max_active = 0
+
+ async def run(self, source_product: SourceProduct, candidate: CandidateProduct, on_update=None):
+ del source_product, on_update
+ self.calls += 1
+ self.active += 1
+ self.max_active = max(self.max_active, self.active)
+ await asyncio.sleep(0.05)
+ self.active -= 1
+ return (
+ ComparisonResult(
+ source_url="https://brand.example/products/alpha-case",
+ product_url=candidate.product_url,
+ marketplace=candidate.marketplace,
+ match_score=0.6,
+ is_exact_match=False,
+ counterfeit_risk_score=0.7,
+ suspicious_signals=["suspiciously_low_price"],
+ reason="Parallel comparison result.",
+ candidate_product=candidate,
+ ),
+ {"tinyfish_status": "COMPLETED", "tinyfish_run_id": f"run-{self.calls}"},
+ )
+
+
+class EmptyEvidenceAgent:
+ async def run(self, source_product: SourceProduct, comparison: ComparisonResult):
+ del source_product, comparison
+ return []
+
+
+class StubReasoningEnrichmentAgent:
+ async def run(self, source_product: SourceProduct, comparison: ComparisonResult):
+ del source_product
+ return ComparisonReasoningEnrichment(
+ source_url=comparison.source_url,
+ product_url=comparison.product_url,
+ enriched_reason="OpenAI enrichment elevated the suspicious-case rationale.",
+ reasoning_notes=["Semantic overlap reinforced the deterministic comparison."],
+ additional_suspicious_signals=["description_semantic_overlap"],
+ risk_adjustment=0.06,
+ match_adjustment=0.02,
+ )
+
+ def apply(self, comparison: ComparisonResult, enrichment):
+ comparison.reason = enrichment.enriched_reason
+ comparison.reasoning_notes = list(enrichment.reasoning_notes)
+ comparison.suspicious_signals = list(
+ dict.fromkeys(comparison.suspicious_signals + enrichment.additional_suspicious_signals)
+ )
+ comparison.counterfeit_risk_score = round(comparison.counterfeit_risk_score + enrichment.risk_adjustment, 2)
+ comparison.match_score = round(comparison.match_score + enrichment.match_adjustment, 2)
+ comparison.reasoning_enrichment_source = "openai"
+ return comparison
+
+
+class PassthroughRankingAgent:
+ async def run(self, comparisons: list[ComparisonResult]):
+ return comparisons
+
+
+class UpdatingSourceAgent:
+ def __init__(self) -> None:
+ self.started = asyncio.Event()
+ self.release = asyncio.Event()
+
+ async def run(self, source_url: str, on_update=None):
+ if on_update is not None:
+ await on_update(
+ TinyFishRun(
+ run_id="run-source-123",
+ status="RUNNING",
+ elapsed_seconds=12.5,
+ last_heartbeat_at=datetime(2026, 3, 21, 10, 0, 5, tzinfo=timezone.utc),
+ last_progress_at=datetime(2026, 3, 21, 10, 0, 3, tzinfo=timezone.utc),
+ )
+ )
+ self.started.set()
+ await self.release.wait()
+ return SourceProduct(source_url=source_url, brand="Brand"), {"tinyfish_run_id": "run-source-123"}
+
+
+class ResumeOnlySourceAgent:
+ def __init__(self) -> None:
+ self.run_calls = 0
+ self.resume_calls = 0
+
+ async def run(self, source_url: str, on_update=None):
+ self.run_calls += 1
+ raise AssertionError("resume path should not start a new TinyFish run")
+
+ async def resume(
+ self,
+ source_url: str,
+ run_id: str,
+ on_update=None,
+ started_at=None,
+ last_progress_at=None,
+ ):
+ self.resume_calls += 1
+ if on_update is not None:
+ await on_update(
+ TinyFishRun(
+ run_id=run_id,
+ status="RUNNING",
+ elapsed_seconds=18.0,
+ last_heartbeat_at=datetime(2026, 3, 21, 10, 0, 9, tzinfo=timezone.utc),
+ last_progress_at=datetime(2026, 3, 21, 10, 0, 7, tzinfo=timezone.utc),
+ )
+ )
+ return SourceProduct(source_url=source_url, brand="Brand"), {
+ "tinyfish_run_id": run_id,
+ "tinyfish_status": "COMPLETED",
+ }
+
+
+def test_orchestrator_persists_inflight_task_progress(tmp_path) -> None:
+ async def run() -> None:
+ store = InvestigationStore(tmp_path / "orchestrator-progress.sqlite3")
+ source_agent = BlockingSourceAgent()
+ orchestrator = InvestigationOrchestrator(
+ store=store,
+ source_agent=source_agent,
+ discovery_agent=EmptyDiscoveryAgent(),
+ summary_agent=SummaryAgent(),
+ )
+ created = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/alpha-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+
+ investigation_task = asyncio.create_task(orchestrator.run_investigation(created.investigation_id))
+ await asyncio.wait_for(source_agent.started.wait(), timeout=1.0)
+
+ in_progress = await store.get(created.investigation_id)
+ assert in_progress is not None
+ assert in_progress.status == InvestigationStatus.running
+ assert len(in_progress.reports) == 1
+ assert in_progress.reports[0].summary == "Extracting official product details."
+ assert len(in_progress.reports[0].raw_agent_outputs) == 1
+ assert in_progress.reports[0].raw_agent_outputs[0].agent_name == "source_extraction"
+ assert in_progress.reports[0].raw_agent_outputs[0].status == TaskStatus.running
+
+ source_agent.release.set()
+ await asyncio.wait_for(investigation_task, timeout=1.0)
+
+ asyncio.run(run())
+
+
+def test_orchestrator_persists_provider_heartbeat_updates(tmp_path) -> None:
+ async def run() -> None:
+ store = InvestigationStore(tmp_path / "orchestrator-heartbeat.sqlite3")
+ source_agent = UpdatingSourceAgent()
+ orchestrator = InvestigationOrchestrator(
+ store=store,
+ source_agent=source_agent,
+ discovery_agent=EmptyDiscoveryAgent(),
+ summary_agent=SummaryAgent(),
+ )
+ created = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/alpha-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+
+ investigation_task = asyncio.create_task(orchestrator.run_investigation(created.investigation_id))
+ await asyncio.wait_for(source_agent.started.wait(), timeout=1.0)
+
+ in_progress = await store.get(created.investigation_id)
+ assert in_progress is not None
+ source_task = in_progress.reports[0].raw_agent_outputs[0]
+ assert source_task.provider_run_id == "run-source-123"
+ assert source_task.provider_status == "RUNNING"
+ assert source_task.last_heartbeat_at == datetime(2026, 3, 21, 10, 0, 5, tzinfo=timezone.utc)
+ assert source_task.last_progress_at == datetime(2026, 3, 21, 10, 0, 3, tzinfo=timezone.utc)
+ assert source_task.output_payload["runtime"]["tinyfish_run_id"] == "run-source-123"
+
+ source_agent.release.set()
+ await asyncio.wait_for(investigation_task, timeout=1.0)
+
+ asyncio.run(run())
+
+
+def test_investigation_store_persists_across_instances(tmp_path) -> None:
+ async def run() -> None:
+ database_path = tmp_path / "investigations.sqlite3"
+ store = InvestigationStore(database_path)
+ created = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/alpha-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+
+ created.status = InvestigationStatus.completed
+ await store.save(created)
+
+ reloaded_store = InvestigationStore(database_path)
+ saved_request = await reloaded_store.get_request(created.investigation_id)
+ saved_investigation = await reloaded_store.get(created.investigation_id)
+
+ assert saved_request.source_urls == ["https://brand.example/products/alpha-case"]
+ assert saved_request.comparison_sites == ["https://shopee.sg/"]
+ assert saved_investigation is not None
+ assert saved_investigation.investigation_id == created.investigation_id
+ assert saved_investigation.status == InvestigationStatus.completed
+
+ asyncio.run(run())
+
+
+def test_investigation_store_lists_active_runs(tmp_path) -> None:
+ async def run() -> None:
+ store = InvestigationStore(tmp_path / "active.sqlite3")
+ active = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/active-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+ completed = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/completed-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+ completed.status = InvestigationStatus.completed
+ await store.save(completed)
+
+ active_runs = await store.list_active()
+ active_ids = {item.investigation_id for item in active_runs}
+
+ assert active.investigation_id in active_ids
+ assert completed.investigation_id not in active_ids
+
+ asyncio.run(run())
+
+
+def test_investigation_store_lists_recent_runs_newest_first(tmp_path) -> None:
+ async def run() -> None:
+ store = InvestigationStore(tmp_path / "recent.sqlite3")
+ first = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/first-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+ second = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/second-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+
+ recent_runs = await store.list_recent(limit=10)
+
+ assert [item.investigation_id for item in recent_runs[:2]] == [
+ second.investigation_id,
+ first.investigation_id,
+ ]
+ assert recent_runs[0].primary_source_url == "https://brand.example/products/second-case"
+
+ asyncio.run(run())
+
+
+def test_orchestrator_resumes_saved_source_run_after_restart(tmp_path) -> None:
+ async def run() -> None:
+ database_path = tmp_path / "resume.sqlite3"
+ store = InvestigationStore(database_path)
+ created = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/alpha-case"],
+ comparison_sites=["https://shopee.sg/"],
+ )
+ )
+
+ investigation = await store.get(created.investigation_id)
+ assert investigation is not None
+ investigation.status = InvestigationStatus.running
+ investigation.reports = [
+ InvestigationReport(
+ source_url="https://brand.example/products/alpha-case",
+ summary="Extracting official product details.",
+ raw_agent_outputs=[
+ AgentTaskState(
+ agent_name="source_extraction",
+ status=TaskStatus.running,
+ input_payload={"source_url": "https://brand.example/products/alpha-case"},
+ output_payload={"runtime": {"tinyfish_run_id": "run-source-123"}},
+ provider_run_id="run-source-123",
+ provider_status="RUNNING",
+ started_at=datetime(2026, 3, 21, 10, 0, 0, tzinfo=timezone.utc),
+ last_heartbeat_at=datetime(2026, 3, 21, 10, 0, 5, tzinfo=timezone.utc),
+ last_progress_at=datetime(2026, 3, 21, 10, 0, 3, tzinfo=timezone.utc),
+ )
+ ],
+ )
+ ]
+ await store.save(investigation)
+
+ source_agent = ResumeOnlySourceAgent()
+ orchestrator = InvestigationOrchestrator(
+ store=InvestigationStore(database_path),
+ source_agent=source_agent,
+ discovery_agent=EmptyDiscoveryAgent(),
+ summary_agent=SummaryAgent(),
+ )
+
+ await orchestrator.run_investigation(created.investigation_id)
+
+ reloaded = await store.get(created.investigation_id)
+ assert reloaded is not None
+ assert source_agent.resume_calls == 1
+ assert source_agent.run_calls == 0
+ assert reloaded.status == InvestigationStatus.completed
+ assert reloaded.reports[0].extracted_source_product is not None
+ source_task = reloaded.reports[0].raw_agent_outputs[0]
+ assert source_task.status == TaskStatus.completed
+ assert source_task.provider_run_id == "run-source-123"
+
+ asyncio.run(run())
+
+
+def test_orchestrator_shortlists_candidates_before_parallel_tinyfish_comparison(tmp_path) -> None:
+ async def run() -> None:
+ store = InvestigationStore(tmp_path / "shortlist.sqlite3")
+ comparison_agent = ParallelComparisonAgent()
+ orchestrator = InvestigationOrchestrator(
+ store=store,
+ source_agent=ImmediateSourceAgent(),
+ discovery_agent=MultiCandidateDiscoveryAgent(),
+ triage_agent=StubTriageAgent(),
+ comparison_agent=comparison_agent,
+ evidence_agent=EmptyEvidenceAgent(),
+ reasoning_enrichment_agent=StubReasoningEnrichmentAgent(),
+ ranking_agent=PassthroughRankingAgent(),
+ summary_agent=SummaryAgent(),
+ )
+ created = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/alpha-case"],
+ comparison_sites=["https://market.example/"],
+ max_shortlisted_candidates=2,
+ )
+ )
+
+ await orchestrator.run_investigation(created.investigation_id)
+
+ saved = await store.get(created.investigation_id)
+ assert saved is not None
+ report = saved.reports[0]
+ triage_tasks = [
+ task for task in report.raw_agent_outputs if task.agent_name == "candidate_triage"
+ ]
+ comparison_tasks = [
+ task for task in report.raw_agent_outputs if task.agent_name == "product_comparison"
+ ]
+ enrichment_tasks = [
+ task for task in report.raw_agent_outputs if task.agent_name == "reasoning_enrichment"
+ ]
+ assert len(triage_tasks) >= 3
+ assert len(comparison_tasks) == 2
+ assert len(enrichment_tasks) == 2
+ assert comparison_agent.calls == 2
+ assert comparison_agent.max_active >= 2
+ assert report.top_matches[0].reasoning_enrichment_source == "openai"
+ assert "description_semantic_overlap" in report.top_matches[0].suspicious_signals
+
+ asyncio.run(run())
diff --git a/TinyDetective/tests/test_seller_case_orchestrator.py b/TinyDetective/tests/test_seller_case_orchestrator.py
new file mode 100644
index 000000000..5375edca3
--- /dev/null
+++ b/TinyDetective/tests/test_seller_case_orchestrator.py
@@ -0,0 +1,250 @@
+"""Seller-case orchestration tests."""
+
+from __future__ import annotations
+
+import asyncio
+
+from agents.case_draft_agent import CaseDraftAgent
+from models.case_schemas import (
+ ActionRequestDraft,
+ OfficialProductMatch,
+ SellerCaseCreateRequest,
+ SellerCaseEvidenceItem,
+ SellerListing,
+ SellerListingTriageAssessment,
+ SellerProfile,
+)
+from models.schemas import (
+ CandidateProduct,
+ ComparisonResult,
+ InvestigationCreateRequest,
+ InvestigationReport,
+ InvestigationStatus,
+ SourceProduct,
+)
+from services.investigation_store import InvestigationStore
+from services.seller_case_orchestrator import SellerCaseOrchestrator
+
+
+class StubSellerProfileAgent:
+ async def run(self, listing_url: str, marketplace: str, seller_name=None, seller_url=None, on_update=None):
+ return (
+ SellerProfile(
+ seller_name=seller_name or "Case Seller",
+ seller_url=seller_url or "https://seller.example/store",
+ marketplace=marketplace,
+ badges=["Top seller"],
+ official_store_claims=["authorized dealer"],
+ storefront_summary="Seller storefront summary.",
+ entry_urls=[seller_url or "https://seller.example/store", listing_url],
+ storefront_shard_urls=[seller_url or "https://seller.example/store", "https://seller.example/store?page=2"],
+ extraction_confidence=0.92,
+ ),
+ {"tinyfish_status": "COMPLETED", "tinyfish_run_id": "profile-run"},
+ )
+
+
+class StubSellerListingDiscoveryAgent:
+ async def run(self, source_product, seller_profile, selected_listing, entry_url, top_n=8, on_update=None):
+ listings = [
+ SellerListing(
+ product_url=selected_listing.product_url,
+ marketplace=selected_listing.marketplace,
+ seller_name=seller_profile.seller_name,
+ seller_store_url=seller_profile.seller_url,
+ title=selected_listing.candidate_product.title,
+ price=selected_listing.candidate_product.price,
+ currency=selected_listing.candidate_product.currency,
+ brand=selected_listing.candidate_product.brand,
+ description=selected_listing.candidate_product.description,
+ discovery_entry_url=entry_url,
+ discovery_shard_url=entry_url,
+ discovery_source="seller_storefront_shard",
+ ),
+ SellerListing(
+ product_url="https://market.example/listing-2",
+ marketplace=selected_listing.marketplace,
+ seller_name=seller_profile.seller_name,
+ seller_store_url=seller_profile.seller_url,
+ title="Brand Alpha Variant",
+ price=55.0,
+ currency="SGD",
+ brand=source_product.brand,
+ description=source_product.description,
+ discovery_entry_url=entry_url,
+ discovery_shard_url=entry_url,
+ discovery_source="seller_storefront_shard",
+ ),
+ ]
+ return listings[:top_n], {"tinyfish_status": "COMPLETED", "tinyfish_run_id": "discovery-run"}
+
+
+class StubSellerListingTriageAgent:
+ async def run(self, source_product, seller_profile, selected_listing, listing):
+ return SellerListingTriageAssessment(
+ product_url=listing.product_url,
+ investigation_priority_score=0.86 if "listing-2" in str(listing.product_url) else 0.74,
+ suspicion_score=0.82 if "listing-2" in str(listing.product_url) else 0.58,
+ should_shortlist=True,
+ rationale="Shortlist this seller listing for deeper review.",
+ suspicious_signals=["suspiciously_low_price"],
+ )
+
+
+class StubOfficialProductMatchAgent:
+ async def run(self, source_product, listing, on_update=None):
+ return (
+ OfficialProductMatch(
+ product_url=listing.product_url,
+ official_product_url=source_product.source_url,
+ official_product=source_product,
+ match_confidence=0.88,
+ rationale="Matched back to the official product page.",
+ search_queries=["brand alpha case"],
+ ),
+ {"discovery_runtime": {"tinyfish_status": "COMPLETED", "tinyfish_run_id": "official-run"}},
+ )
+
+
+class StubSellerListingAnalysisAgent:
+ async def run(self, source_product, listing, on_update=None):
+ candidate = CandidateProduct(
+ product_url=listing.product_url,
+ marketplace=listing.marketplace,
+ seller_name=listing.seller_name,
+ seller_store_url=listing.seller_store_url,
+ title=listing.title,
+ price=listing.price,
+ currency=listing.currency,
+ brand=listing.brand or source_product.brand,
+ description=listing.description,
+ )
+ result = ComparisonResult(
+ source_url=source_product.source_url,
+ product_url=listing.product_url,
+ marketplace=listing.marketplace,
+ match_score=0.62,
+ is_exact_match=False,
+ counterfeit_risk_score=0.81 if "listing-2" in str(listing.product_url) else 0.74,
+ suspicious_signals=["suspiciously_low_price", "copied_description_with_discount_pricing"],
+ reason="Repeated low-price listing tied to the same seller.",
+ evidence=[],
+ candidate_product=candidate,
+ )
+ return result, {"tinyfish_status": "COMPLETED", "tinyfish_run_id": f"analysis-{listing.product_url}"}
+
+
+class StubSellerEvidenceAgent:
+ async def run(self, source_product, seller_profile, selected_listing, suspect_listings, official_matches):
+ return [
+ SellerCaseEvidenceItem(
+ type="repeat_product_family_pattern",
+ title="Repeated suspicious listings",
+ note="Multiple suspicious listings were found on the same storefront.",
+ reference_url=seller_profile.seller_url,
+ confidence=0.88,
+ )
+ ]
+
+
+class StubCaseDraftAgent(CaseDraftAgent):
+ async def run(self, source_product, seller_profile, selected_listing, suspect_listings, evidence, official_matches):
+ return ActionRequestDraft(
+ case_title="Seller enforcement case",
+ summary="Seller case prepared.",
+ reasoning="Evidence supports marketplace escalation.",
+ suspected_violation_type="suspected counterfeit / trademark misuse",
+ recommended_action="seller suspension review",
+ request_text="Please review and take action.",
+ evidence_references=[str(seller_profile.seller_url)],
+ confidence=0.91,
+ )
+
+
+def test_seller_case_orchestrator_builds_case_from_existing_investigation(tmp_path) -> None:
+ async def run() -> None:
+ store = InvestigationStore(tmp_path / "seller-case.sqlite3")
+ created = await store.create(
+ InvestigationCreateRequest(
+ source_urls=["https://brand.example/products/alpha-case"],
+ comparison_sites=["https://market.example/"],
+ )
+ )
+
+ source_product = SourceProduct(
+ source_url="https://brand.example/products/alpha-case",
+ brand="Brand",
+ product_name="Alpha Case",
+ category="Phone Case",
+ description="Protective phone case",
+ price=120.0,
+ currency="SGD",
+ )
+ seed_listing = ComparisonResult(
+ source_url=source_product.source_url,
+ product_url="https://market.example/listing-1",
+ marketplace="Market",
+ match_score=0.58,
+ is_exact_match=False,
+ counterfeit_risk_score=0.72,
+ suspicious_signals=["suspiciously_low_price"],
+ reason="Seed suspicious listing.",
+ evidence=[],
+ candidate_product=CandidateProduct(
+ product_url="https://market.example/listing-1",
+ marketplace="Market",
+ seller_name="Case Seller",
+ seller_store_url="https://seller.example/store",
+ title="Brand Alpha Case",
+ price=60.0,
+ currency="SGD",
+ brand="Brand",
+ description="Protective phone case",
+ ),
+ )
+
+ investigation = await store.get(created.investigation_id)
+ assert investigation is not None
+ investigation.status = InvestigationStatus.completed
+ investigation.reports = [
+ InvestigationReport(
+ source_url=source_product.source_url,
+ extracted_source_product=source_product,
+ top_matches=[seed_listing],
+ summary="Completed.",
+ )
+ ]
+ await store.save(investigation)
+
+ seller_case = await store.create_case(
+ SellerCaseCreateRequest(
+ investigation_id=created.investigation_id,
+ source_url=source_product.source_url,
+ product_url=seed_listing.product_url,
+ )
+ )
+
+ orchestrator = SellerCaseOrchestrator(
+ store=store,
+ seller_profile_agent=StubSellerProfileAgent(),
+ seller_listing_discovery_agent=StubSellerListingDiscoveryAgent(),
+ seller_listing_triage_agent=StubSellerListingTriageAgent(),
+ official_product_match_agent=StubOfficialProductMatchAgent(),
+ seller_listing_analysis_agent=StubSellerListingAnalysisAgent(),
+ seller_evidence_agent=StubSellerEvidenceAgent(),
+ case_draft_agent=StubCaseDraftAgent(),
+ )
+ await orchestrator.run_case(seller_case.case_id)
+
+ saved = await store.get_case(seller_case.case_id)
+ assert saved is not None
+ assert saved.status == "completed"
+ assert saved.seller_profile is not None
+ assert len(saved.discovered_listings) >= 2
+ assert len(saved.triage_assessments) >= 1
+ assert len(saved.official_product_matches) >= 1
+ assert len(saved.suspect_listings) >= 1
+ assert saved.action_request_draft is not None
+ assert saved.summary == "Seller case prepared."
+
+ asyncio.run(run())
diff --git a/TinyDetective/uv.lock b/TinyDetective/uv.lock
new file mode 100644
index 000000000..e6d894132
--- /dev/null
+++ b/TinyDetective/uv.lock
@@ -0,0 +1,387 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.135.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+ { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
+ { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "0.52.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
+]
+
+[[package]]
+name = "tinydetective"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "fastapi" },
+ { name = "pydantic" },
+ { name = "tinyfish" },
+ { name = "uvicorn" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "fastapi", specifier = ">=0.116.0" },
+ { name = "pydantic", specifier = ">=2.11.0" },
+ { name = "tinyfish", specifier = ">=0.2.4" },
+ { name = "uvicorn", specifier = ">=0.35.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.3.0" }]
+
+[[package]]
+name = "tinyfish"
+version = "0.2.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "tenacity" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/11/0c/2ed6f57ad3247408da5a04acb9ab75f657f39afcb96469723cb27ded3d55/tinyfish-0.2.4.tar.gz", hash = "sha256:2a12a4204e0c44debfbcf5bcf0b7cb2e33b10ebe6b80e4109c1cef6869679c5e", size = 53611, upload-time = "2026-03-20T17:25:44.747Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/ad/6210ddf5324b5adcdfce5006736edd77b2642c5c855f5fa410f6b085c1f7/tinyfish-0.2.4-py3-none-any.whl", hash = "sha256:821fcfcab08bf1bff7a071cc1f1bdaa8f9358308d250773059e1c40a0ba4c02a", size = 23432, upload-time = "2026-03-20T17:25:43.543Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.42.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
+]