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 + +![TinyDetective](./frontend/tinydetective.png) + +## 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"}` + )}
+
+
Category${escapeHtml(product.category || "Unavailable")}
+
SKU${escapeHtml(product.sku || "Unavailable")}
+
Model${escapeHtml(product.model || "Unavailable")}
+
Price${escapeHtml( + product.price !== null && product.price !== undefined + ? formatCompactCurrency(product.price, product.currency) + : "Unavailable" + )}
+
+ ` + : ` + Source status +
${escapeHtml(formatHostname(sourceUrl))}
+

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

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

No marketplace queries have started yet.

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

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

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

No candidate listings have been captured yet.

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

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

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

No comparison graph is available yet.

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

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

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

Reasoning traces will appear here once comparison begins.

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

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

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

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

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

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

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

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

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

    No ranked matches were returned.

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

    No evidence items returned.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    TinyFish has not returned seller profile data yet.

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

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

    + ${profile.seller_url ? `

    Open storefront

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

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

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

    ${escapeHtml(selectedListing.product_url)}

    +

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

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

    No suspect seller listings have been confirmed yet.

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

    ${escapeHtml(listing.product_url)}

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

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

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

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

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

    No evidence objects are available yet.

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

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

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

    ${escapeHtml(item.reference_url)}

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

    The action-request draft is still being prepared.

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

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

    +
    +
    + Recommended Action +

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

    +

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

    +
    +
    + Reasoning +

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

    +
    +
    + Marketplace Request +

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

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

    No evidence references were returned.

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

    No activity has been recorded yet.

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

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

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

    TinyDetective

    +
    +
    +

    Counterfeit Research Console

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

    Agent Progress

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

    + No investigation running yet. +

    +
    + +
    +
    +
    + 01 +
    +

    Step 1

    + Source Page +
    +
    +
    + 02 +
    +

    Step 2

    + Live Search +
    +
    +
    + 03 +
    +

    Step 3

    + Candidate Intake +
    +
    +
    + 04 +
    +

    Step 4

    + Reasoning Graph +
    +
    +
    + 05 +
    +

    Step 5

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

    Step 1

    +

    Source Page

    +
    + Pending +
    +

    + Waiting for the official product page. +

    +
    +
    +
    + +

    No source URL selected yet.

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

    Step 2

    +

    Live Search Behavior

    +
    + Pending +
    +

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

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

    Step 3

    +

    Candidate Intake

    +
    + Pending +
    +

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

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

    Step 4

    +

    Reasoning Graph

    +
    + Pending +
    +

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

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

    Step 5

    +

    Ranking Ladder

    +
    + Pending +
    +

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

    +
      +
      + +
      +
      +
      +
      +
      +
      + +

      + Waiting for an investigation to start. +

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

      Evidence Dossier

      +
      +
      + + +
      +
      + +
      +

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

      +

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

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

      Seller Enforcement Case

      +

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

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

      + No seller case has been started yet. +

      +
      +
      +
      +
      +
      + +
      +
      +
      +

      Storefront

      +

      Seller Profile

      +
      +
      +
      + +
      +
      +

      Seed Listing

      +

      Selected Listing

      +
      +
      +
      + +
      +
      +

      Inventory

      +

      Suspicious Seller Listings

      +
      +
      +
      + +
      +
      +

      Evidence

      +

      Case Evidence

      +
      +
      +
      + +
      +
      +

      Action Draft

      +

      Marketplace Request

      +
      +
      +
      + +
      +
      +

      Activity

      +

      Agent Activity

      +
      +
      +
      + Raw task log +
      +
      +
      +
      +
      +
      +
      +
      + + + + + + + + + diff --git a/TinyDetective/frontend/logos/amazon.png b/TinyDetective/frontend/logos/amazon.png new file mode 100644 index 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 @@ +eBay \ No newline at end of file diff --git a/TinyDetective/frontend/logos/facebook-marketplace.svg b/TinyDetective/frontend/logos/facebook-marketplace.svg new file mode 100644 index 000000000..ec55fb45b --- /dev/null +++ b/TinyDetective/frontend/logos/facebook-marketplace.svg @@ -0,0 +1,6 @@ + + + diff --git a/TinyDetective/frontend/logos/facebook.svg b/TinyDetective/frontend/logos/facebook.svg new file mode 100644 index 000000000..f66767e46 --- /dev/null +++ b/TinyDetective/frontend/logos/facebook.svg @@ -0,0 +1 @@ +Facebook \ No newline at end of file diff --git a/TinyDetective/frontend/logos/lazada.png b/TinyDetective/frontend/logos/lazada.png new file mode 100644 index 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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" }, +]