diff --git a/.env.example b/.env.example index e4b6663..dcf3e68 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ LLM_API_KEY= LLM_BASE_URL= LLM_MODEL= EMBEDDING_MODEL= +SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING= REDDIT_CLIENT_ID= REDDIT_CLIENT_SECRET= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..bcf97d8 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,20 @@ +DATABASE_URL= +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= +SIGNALFORGE_REQUIRE_OWNER_AUTH=false + +LLM_API_KEY= +LLM_BASE_URL= +LLM_MODEL= +EMBEDDING_MODEL= +SIGNALFORGE_ALLOW_REAL_LLM_SMOKE= +SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING= +SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE= + +REDDIT_CLIENT_ID= +REDDIT_CLIENT_SECRET= +REDDIT_USER_AGENT= +PRODUCT_HUNT_TOKEN= +SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE= +SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE= diff --git a/.github/workflows/ci-acceptance.yml b/.github/workflows/ci-acceptance.yml index e30f5c0..e2820f1 100644 --- a/.github/workflows/ci-acceptance.yml +++ b/.github/workflows/ci-acceptance.yml @@ -14,6 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Validate docs run: python3 scripts/validate_docs.py - name: Validate acceptance assets diff --git a/.gitignore b/.gitignore index fe8d17f..889e18d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env .env.* !.env.example +!.env.production.example node_modules/ .venv/ @@ -10,8 +11,10 @@ __pycache__/ .mypy_cache/ .next/ +apps/web/test-results/ dist/ build/ +backups/ .DS_Store *.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9e8c6a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# SignalForge Agent Instructions + +## Hermes/Codex local skills + +This repo uses repo-local Hermes/Codex skills stored under: + +`docs/agents/skills/hermes-codex/` + +These skills are also installed globally under `~/.codex/skills/hermes-*` for Codex discovery. +The repo-local copies remain the SignalForge source of truth. Do not resync, overwrite, or remove +global copies unless the owner explicitly approves a separate global-sync step. + +Use these skills when the user names them directly or the request matches their trigger: + +- `hermes-diagnose` - bugs, failures, regressions, signal/data/connector/debug work. +- `hermes-tdd` - test-first feature or bug work through public behavior. +- `hermes-zoom-out` - system maps and unfamiliar code areas. +- `hermes-grill-with-docs` - plan review, domain language, CONTEXT/ADR proposals. +- `hermes-improve-codebase-architecture` - architecture friction and deep-module candidates. +- `hermes-to-prd` - PRD draft from current context. +- `hermes-to-issues` - local Markdown issue slices from a PRD or plan. +- `hermes-triage` - local Markdown triage notes and agent briefs. +- `hermes-write-a-skill` - create or update SignalForge-local Hermes/Codex skills. + +## Mandatory safety protocol + +Before any write action, state: + +- Task judgment +- Current goal +- Confirmed facts +- Risks +- Recommended approach +- Change boundary +- Implementation steps +- Verification standard + +After implementation, report: + +- Modified files +- Purpose of changes +- Commands run +- Verification result +- Residual issues +- Rollback method + +Default to draft-only for PRDs, issues, triage notes, CONTEXT changes, and ADRs. Drafts live +under `docs/agents/drafts/` unless the user approves another path. + +Do not perform these actions without explicit owner approval: + +- Global skill installation or sync to `~/.codex/skills` +- `git push`, `git reset`, `git clean`, destructive delete commands +- Issue tracker writes, label changes, comments, or closes +- Production logic changes +- Dependency installation +- Hook, CI, deployment, secret, credential, or permission changes +- Real platform, real LLM, real embedding, or production smoke actions diff --git a/README.md b/README.md index c94b453..8d7de00 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,8 @@ If `http://localhost:3000` is reachable, `scripts/validate_frontend_mvp.py` also Expected Phase 6 behavior: - Required frontend pages exist. -- The UI includes Open Source evidence text and a high value marker. +- The UI includes Open Source / 打开来源 evidence text and a High Value / 高价值 marker. +- The frontend UI is localized to Chinese while preserving backend enum values, URL paths, and API request parameters. - Settings does not render `encrypted_payload`. - Reports expose markdown and csv export controls. - The frontend API client accepts only SignalForge backend-relative paths. diff --git a/apps/api/app/api/auth.py b/apps/api/app/api/auth.py new file mode 100644 index 0000000..c1ba537 --- /dev/null +++ b/apps/api/app/api/auth.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import hmac +import os + +from fastapi import Request, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse, Response + +from app.api.errors import error_body + + +OWNER_TOKEN_HEADER = "X-SignalForge-Owner-Token" + + +def env_flag_enabled(name: str) -> bool: + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +class OwnerAuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + if not _requires_owner_auth(request): + return await call_next(request) + + expected_token = os.getenv("SIGNALFORGE_OWNER_API_TOKEN", "").strip() + if not expected_token: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content=error_body( + "owner_auth_not_configured", + "Owner API authentication is required but not configured.", + ), + ) + + provided_token = request.headers.get(OWNER_TOKEN_HEADER, "") + if not hmac.compare_digest(provided_token, expected_token): + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content=error_body( + "owner_auth_required", + "Owner API authentication is required.", + ), + ) + + return await call_next(request) + + +def _requires_owner_auth(request: Request) -> bool: + if not env_flag_enabled("SIGNALFORGE_REQUIRE_OWNER_AUTH"): + return False + if request.url.path == "/health": + return False + return request.url.path.startswith("/api/") diff --git a/apps/api/app/api/routes/collection_jobs.py b/apps/api/app/api/routes/collection_jobs.py index cfa6992..0be70da 100644 --- a/apps/api/app/api/routes/collection_jobs.py +++ b/apps/api/app/api/routes/collection_jobs.py @@ -5,8 +5,10 @@ from fastapi import APIRouter, Body from sqlalchemy import select +from app.api.auth import env_flag_enabled from app.api.errors import phase_not_available from app.api.deps import DbSession +from app.connectors.credential_resolver import REAL_PLATFORM_SMOKE_ENV from app.db.models import CollectionLog from app.schemas.collection_logs import CollectionLogRead from app.schemas.collection_jobs import ( @@ -33,6 +35,8 @@ def create_collection_job( execution_mode = _execution_mode(payload) if execution_mode not in PHASE_4_EXECUTION_MODES: raise phase_not_available(PHASE_4_FORBIDDEN_MODE_MESSAGE) + if _real_platform_write_required(execution_mode) and not env_flag_enabled("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE"): + raise phase_not_available("Real platform collection write requires SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE=true.") job = execute_collection(db, project_id=project_id, execution_mode=execution_mode) @@ -64,3 +68,7 @@ def _execution_mode(payload: CollectionJobCreateRequest | None) -> str: raise phase_not_available(PHASE_4_FORBIDDEN_MODE_MESSAGE) normalized = mode.strip() return normalized or DEFAULT_EXECUTION_MODE + + +def _real_platform_write_required(execution_mode: str) -> bool: + return execution_mode in {"reddit", "product_hunt", "p0_real"} and env_flag_enabled(REAL_PLATFORM_SMOKE_ENV) diff --git a/apps/api/app/api/routes/production_runs.py b/apps/api/app/api/routes/production_runs.py new file mode 100644 index 0000000..edc7246 --- /dev/null +++ b/apps/api/app/api/routes/production_runs.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, status + +from app.api.deps import DbSession, PaginationDep +from app.schemas.common import PaginatedResponse +from app.schemas.production_runs import ProductionRunCloseout, ProductionRunCreate, ProductionRunRead +from app.services import production_runs as production_run_service +from app.services.common import paginated_response + + +router = APIRouter(prefix="/api/production/runs", tags=["production"]) + + +@router.get("", response_model=PaginatedResponse[ProductionRunRead]) +def list_production_runs(db: DbSession, pagination: PaginationDep) -> PaginatedResponse[ProductionRunRead]: + runs, total = production_run_service.list_runs(db, pagination) + return paginated_response(runs, pagination, total) + + +@router.post("", response_model=ProductionRunRead, status_code=status.HTTP_201_CREATED) +def create_production_run(payload: ProductionRunCreate, db: DbSession) -> ProductionRunRead: + return production_run_service.create_run(db, payload) + + +@router.get("/{run_id}", response_model=ProductionRunRead) +def get_production_run(run_id: UUID, db: DbSession) -> ProductionRunRead: + return production_run_service.get_run(db, run_id) + + +@router.post("/{run_id}/closeout", response_model=ProductionRunRead) +def closeout_production_run(run_id: UUID, payload: ProductionRunCloseout, db: DbSession) -> ProductionRunRead: + return production_run_service.closeout_run(db, run_id, payload) diff --git a/apps/api/app/api/routes/settings.py b/apps/api/app/api/routes/settings.py index bd8dee8..40e0ae7 100644 --- a/apps/api/app/api/routes/settings.py +++ b/apps/api/app/api/routes/settings.py @@ -2,8 +2,9 @@ from fastapi import APIRouter +from app.api.errors import not_found from app.api.deps import DbSession -from app.schemas.settings import CredentialStatusResponse, PlatformsResponse +from app.schemas.settings import CredentialStatusResponse, PlatformEnvTestResponse, PlatformsResponse from app.services import settings as settings_service @@ -18,3 +19,11 @@ def list_platforms(db: DbSession) -> PlatformsResponse: @router.get("/credentials/status", response_model=CredentialStatusResponse) def list_credential_statuses(db: DbSession) -> CredentialStatusResponse: return CredentialStatusResponse(credentials=settings_service.list_credential_statuses(db)) + + +@router.post("/platforms/{platform}/test", response_model=PlatformEnvTestResponse) +def test_platform_env_status(platform: str) -> PlatformEnvTestResponse: + try: + return settings_service.test_platform_env_status(platform) + except ValueError as exc: + raise not_found("Unsupported platform", {"platform": platform}) from exc diff --git a/apps/api/app/config.py b/apps/api/app/config.py index ce7c014..b1e7265 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -1,11 +1,29 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field import os +DEFAULT_CORS_ALLOW_ORIGINS = ("http://localhost:3000", "http://127.0.0.1:3000") + + +def parse_cors_allow_origins(value: str | None) -> tuple[str, ...]: + if value is None: + return DEFAULT_CORS_ALLOW_ORIGINS + + origins: list[str] = [] + for origin in value.split(","): + normalized = origin.strip().rstrip("/") + if normalized: + origins.append(normalized) + return tuple(origins) or DEFAULT_CORS_ALLOW_ORIGINS + + @dataclass(frozen=True) class Settings: database_url: str = os.getenv("DATABASE_URL", "") redis_url: str = os.getenv("REDIS_URL", "") + cors_allow_origins: tuple[str, ...] = field( + default_factory=lambda: parse_cors_allow_origins(os.getenv("CORS_ALLOW_ORIGINS")) + ) settings = Settings() diff --git a/apps/api/app/connectors/mock.py b/apps/api/app/connectors/mock.py index cfe06e8..90b1ba5 100644 --- a/apps/api/app/connectors/mock.py +++ b/apps/api/app/connectors/mock.py @@ -18,15 +18,16 @@ def __init__(self) -> None: super().__init__(self.platform) def collect(self, config: ProjectCollectionConfig) -> ConnectorResult: + item_prefix = str(config.project_id) items = [ NormalizedRawItem( platform=self.platform, - platform_item_id="mock-001", + platform_item_id=f"{item_prefix}-mock-001", source_url="https://example.local/mock/mock-001", author_hash="mock-author-alpha", - content_text="SignalForge mock item for wallet onboarding feedback.", + content_text="I need a less confusing onboarding flow for wallet setup and position tracking.", content_excerpt="Wallet onboarding feedback", - normalized_text="signalforge mock item for wallet onboarding feedback", + normalized_text="i need a less confusing onboarding flow for wallet setup and position tracking", language="en", engagement={"score": 12, "comments": 3}, keyword_hits=["wallet", "onboarding"], @@ -35,12 +36,12 @@ def collect(self, config: ProjectCollectionConfig) -> ConnectorResult: ), NormalizedRawItem( platform=self.platform, - platform_item_id="mock-002", + platform_item_id=f"{item_prefix}-mock-002", source_url="https://example.local/mock/mock-002", author_hash="mock-author-beta", - content_text="Mock complaint about pricing clarity during checkout.", + content_text="Looking for an alternative to expensive market research tools with clearer pricing.", content_excerpt="Pricing clarity during checkout", - normalized_text="mock complaint about pricing clarity during checkout", + normalized_text="looking for an alternative to expensive market research tools with clearer pricing", language="en", engagement={"score": 8, "comments": 1}, keyword_hits=["pricing", "checkout"], @@ -49,18 +50,46 @@ def collect(self, config: ProjectCollectionConfig) -> ConnectorResult: ), NormalizedRawItem( platform=self.platform, - platform_item_id="mock-003", + platform_item_id=f"{item_prefix}-mock-003", source_url="https://example.local/mock/mock-003", author_hash="mock-author-gamma", - content_text="Static request for better integration docs.", + content_text="We need better integration docs before we can adopt this workflow.", content_excerpt="Better integration documentation", - normalized_text="static request for better integration docs", + normalized_text="we need better integration docs before we can adopt this workflow", language="en", engagement={"score": 15, "comments": 5}, keyword_hits=["integration", "documentation"], raw_payload={"source": "static_mock", "sequence": 3}, created_at_source=datetime(2026, 1, 3, 14, 15, tzinfo=UTC), ), + NormalizedRawItem( + platform=self.platform, + platform_item_id=f"{item_prefix}-mock-004", + source_url="https://example.local/mock/mock-004", + author_hash="mock-author-delta", + content_text="The current signal workflow is too noisy and hard to turn into next actions.", + content_excerpt="Signal workflow is too noisy", + normalized_text="the current signal workflow is too noisy and hard to turn into next actions", + language="en", + engagement={"score": 22, "comments": 7}, + keyword_hits=["workflow", "actions"], + raw_payload={"source": "static_mock", "sequence": 4}, + created_at_source=datetime(2026, 1, 4, 16, 0, tzinfo=UTC), + ), + NormalizedRawItem( + platform=self.platform, + platform_item_id=f"{item_prefix}-mock-005", + source_url="https://example.local/mock/mock-005", + author_hash="mock-author-epsilon", + content_text="I wish there was a simple report that tells me what to build next.", + content_excerpt="Report should tell what to build next", + normalized_text="i wish there was a simple report that tells me what to build next", + language="en", + engagement={"score": 18, "comments": 4}, + keyword_hits=["report", "next action"], + raw_payload={"source": "static_mock", "sequence": 5}, + created_at_source=datetime(2026, 1, 5, 11, 45, tzinfo=UTC), + ), ] return ConnectorResult( diff --git a/apps/api/app/connectors/product_hunt.py b/apps/api/app/connectors/product_hunt.py index f638084..457e5e7 100644 --- a/apps/api/app/connectors/product_hunt.py +++ b/apps/api/app/connectors/product_hunt.py @@ -17,6 +17,7 @@ from app.connectors.product_hunt_queries import ( PRODUCT_HUNT_GRAPHQL_ENDPOINT, build_posts_search_query, + build_topic_posts_query, ) from app.connectors.types import ( ConnectorResult, @@ -33,6 +34,7 @@ "Accept": "application/json", "Content-Type": "application/json", } +PRODUCT_HUNT_BODY_EXCERPT_CHARS = 1_048_576 PERMISSION_ERROR_MARKERS = ( "permission", "permissions", @@ -117,84 +119,91 @@ def collect(self, config: ProjectCollectionConfig) -> ConnectorResult: metadata=credentials.safe_metadata(), ) - payload = build_posts_search_query( - keywords=config.keywords, - first=config.max_items, - ) - try: - response = self._client().request( - "POST", - PRODUCT_HUNT_GRAPHQL_ENDPOINT, - authorization=authorization, - headers=REQUEST_HEADERS, - json=payload, - ) - except httpx.HTTPError as exc: - return ConnectorResult( - platform=self.platform, - status=ConnectorStatus.FAILED, - error_message=str(exc), - metadata=credentials.safe_metadata(), - ) - - rate_limit_state = _rate_limit_state_from_headers(response.headers) - if response.status_code in {401, 403}: - return ConnectorResult( - platform=self.platform, - status=ConnectorStatus.PERMISSION_LIMITED, - error_message=f"Product Hunt HTTP {response.status_code}", - rate_limit_state=rate_limit_state, - metadata=credentials.safe_metadata(), - ) - if response.status_code == 429: - return ConnectorResult( - platform=self.platform, - status=ConnectorStatus.RATE_LIMITED, - error_message="Product Hunt rate limit reached", - rate_limit_state=rate_limit_state, - metadata=credentials.safe_metadata(), - ) - if response.status_code >= 400: - return ConnectorResult( - platform=self.platform, - status=ConnectorStatus.FAILED, - error_message=f"Product Hunt HTTP {response.status_code}", - rate_limit_state=rate_limit_state, - metadata=credentials.safe_metadata(), - ) - - body = _parse_json_body(response.body_excerpt) - if body is None: - return ConnectorResult( - platform=self.platform, - status=ConnectorStatus.FAILED, - error_message="Product Hunt returned invalid JSON", - rate_limit_state=rate_limit_state, - metadata=credentials.safe_metadata(), - ) - - errors = body.get("errors") - if errors: - status = _status_from_graphql_errors(errors) - return ConnectorResult( - platform=self.platform, - status=status, - error_message=_graphql_error_summary(errors), - rate_limit_state=rate_limit_state, - metadata=credentials.safe_metadata(), - ) - - data = body.get("data") - if not isinstance(data, Mapping): - return ConnectorResult( - platform=self.platform, - status=ConnectorStatus.FAILED, - error_message="Product Hunt response missing data", - rate_limit_state=rate_limit_state, - metadata=credentials.safe_metadata(), - ) + payloads = _collection_payloads(config) + items: list[NormalizedRawItem] = [] + seen_ids: set[str] = set() + skipped = 0 + rate_limit_state: RateLimitState | None = None + + for payload in payloads: + try: + response = self._client().request( + "POST", + PRODUCT_HUNT_GRAPHQL_ENDPOINT, + authorization=authorization, + headers=REQUEST_HEADERS, + json=payload, + ) + except httpx.HTTPError as exc: + return ConnectorResult( + platform=self.platform, + status=ConnectorStatus.FAILED, + error_message=str(exc), + metadata=credentials.safe_metadata(), + ) + + rate_limit_state = _rate_limit_state_from_headers(response.headers) + if response.status_code in {401, 403}: + return ConnectorResult( + platform=self.platform, + status=ConnectorStatus.PERMISSION_LIMITED, + error_message=f"Product Hunt HTTP {response.status_code}", + rate_limit_state=rate_limit_state, + metadata=credentials.safe_metadata(), + ) + if response.status_code == 429: + return ConnectorResult( + platform=self.platform, + status=ConnectorStatus.RATE_LIMITED, + error_message="Product Hunt rate limit reached", + rate_limit_state=rate_limit_state, + metadata=credentials.safe_metadata(), + ) + if response.status_code >= 400: + return ConnectorResult( + platform=self.platform, + status=ConnectorStatus.FAILED, + error_message=f"Product Hunt HTTP {response.status_code}", + rate_limit_state=rate_limit_state, + metadata=credentials.safe_metadata(), + ) + + body = _parse_json_body(response.body_excerpt) + if body is None: + return ConnectorResult( + platform=self.platform, + status=ConnectorStatus.FAILED, + error_message="Product Hunt returned invalid JSON", + rate_limit_state=rate_limit_state, + metadata=credentials.safe_metadata(), + ) + + errors = body.get("errors") + if errors: + status = _status_from_graphql_errors(errors) + return ConnectorResult( + platform=self.platform, + status=status, + error_message=_graphql_error_summary(errors), + rate_limit_state=rate_limit_state, + metadata=credentials.safe_metadata(), + ) + + data = body.get("data") + if not isinstance(data, Mapping): + return ConnectorResult( + platform=self.platform, + status=ConnectorStatus.FAILED, + error_message="Product Hunt response missing data", + rate_limit_state=rate_limit_state, + metadata=credentials.safe_metadata(), + ) + + response_items, response_skipped = normalize_product_hunt_data(data, config=config) + skipped += response_skipped + for item in response_items: + _append_unique(items, seen_ids, item) - items, skipped = normalize_product_hunt_data(data, config=config) return ConnectorResult( platform=self.platform, status=ConnectorStatus.SUCCESS, @@ -214,7 +223,7 @@ def _client(self) -> ConnectorHTTPClient: self._http_client = ConnectorHTTPClient( timeout=10.0, transport=self._transport, - body_excerpt_chars=65536, + body_excerpt_chars=PRODUCT_HUNT_BODY_EXCERPT_CHARS, ) return self._http_client @@ -267,6 +276,39 @@ def normalize_product_hunt_data( return items, skipped +def _collection_payloads(config: ProjectCollectionConfig) -> list[dict[str, Any]]: + topic_keywords = _topic_keywords(config.keywords) + if topic_keywords: + return [ + build_topic_posts_query( + keyword=keyword, + first=config.max_items, + ) + for keyword in topic_keywords + ] + return [ + build_posts_search_query( + keywords=config.keywords, + first=config.max_items, + ) + ] + + +def _topic_keywords(keywords: list[str]) -> list[str]: + normalized_keywords: list[str] = [] + seen: set[str] = set() + for keyword in keywords: + normalized = keyword.strip() + key = normalized.lower() + if not normalized or key in seen: + continue + seen.add(key) + normalized_keywords.append(normalized) + if len(normalized_keywords) == 3: + break + return normalized_keywords + + def _normalize_post( node: Mapping[str, Any], *, @@ -394,10 +436,20 @@ def _post_nodes(data: Mapping[str, Any]) -> list[Mapping[str, Any]]: post = data.get("post") if isinstance(post, Mapping): nodes.append(post) + for topic in _topic_nodes(data): + nodes.extend(_connection_nodes(topic.get("posts"))) nodes.extend(_typed_search_nodes(data, {"Post"})) return nodes +def _topic_nodes(data: Mapping[str, Any]) -> list[Mapping[str, Any]]: + nodes = _connection_nodes(data.get("topics")) + topic = data.get("topic") + if isinstance(topic, Mapping): + nodes.append(topic) + return nodes + + def _product_nodes(data: Mapping[str, Any]) -> list[Mapping[str, Any]]: nodes = _connection_nodes(data.get("products")) product = data.get("product") diff --git a/apps/api/app/connectors/product_hunt_queries.py b/apps/api/app/connectors/product_hunt_queries.py index 1ad81c3..f341a09 100644 --- a/apps/api/app/connectors/product_hunt_queries.py +++ b/apps/api/app/connectors/product_hunt_queries.py @@ -9,6 +9,8 @@ DEFAULT_COMMENTS_FIRST = 5 MAX_POSTS_FIRST = 100 MAX_COMMENTS_FIRST = 25 +DEFAULT_TOPICS_FIRST = 3 +MAX_TOPICS_FIRST = 3 def build_posts_search_query( @@ -29,12 +31,10 @@ def build_posts_search_query( minimum=0, maximum=MAX_COMMENTS_FIRST, ) - search_query = " ".join(keyword.strip() for keyword in keywords if keyword.strip()) - return { "query": """ -query SignalForgeProductHuntPosts($query: String!, $first: Int!, $commentsFirst: Int!) { - posts(first: $first, search: $query) { +query SignalForgeProductHuntPosts($first: Int!, $commentsFirst: Int!) { + posts(first: $first) { edges { node { id @@ -67,7 +67,53 @@ def build_posts_search_query( } } } - products { + } + } + } +} +""".strip(), + "variables": { + "first": normalized_first, + "commentsFirst": normalized_comments_first, + }, + } + + +def build_topic_posts_query( + *, + keyword: str, + first: int | None = None, + comments_first: int | None = None, + topics_first: int | None = None, +) -> dict[str, Any]: + normalized_first = _bounded_int( + first, + default=DEFAULT_POSTS_FIRST, + minimum=1, + maximum=MAX_POSTS_FIRST, + ) + normalized_comments_first = _bounded_int( + comments_first, + default=DEFAULT_COMMENTS_FIRST, + minimum=0, + maximum=MAX_COMMENTS_FIRST, + ) + normalized_topics_first = _bounded_int( + topics_first, + default=DEFAULT_TOPICS_FIRST, + minimum=1, + maximum=MAX_TOPICS_FIRST, + ) + return { + "query": """ +query SignalForgeProductHuntTopics($query: String!, $topicsFirst: Int!, $first: Int!, $commentsFirst: Int!) { + topics(query: $query, first: $topicsFirst) { + edges { + node { + id + slug + name + posts(first: $first) { edges { node { id @@ -77,6 +123,29 @@ def build_posts_search_query( description url website + votesCount + commentsCount + createdAt + user { + id + username + name + } + comments(first: $commentsFirst) { + edges { + node { + id + body + url + createdAt + user { + id + username + name + } + } + } + } } } } @@ -86,7 +155,8 @@ def build_posts_search_query( } """.strip(), "variables": { - "query": search_query, + "query": keyword.strip(), + "topicsFirst": normalized_topics_first, "first": normalized_first, "commentsFirst": normalized_comments_first, }, @@ -165,6 +235,7 @@ def _bounded_int( build_product_hunt_posts_query = build_posts_search_query build_product_hunt_product_query = build_product_lookup_query +build_product_hunt_topic_posts_query = build_topic_posts_query __all__ = [ @@ -172,6 +243,8 @@ def _bounded_int( "PRODUCT_HUNT_GRAPHQL_URL", "build_product_hunt_posts_query", "build_product_hunt_product_query", + "build_product_hunt_topic_posts_query", "build_posts_search_query", "build_product_lookup_query", + "build_topic_posts_query", ] diff --git a/apps/api/app/db/models.py b/apps/api/app/db/models.py index b10b603..dbe6094 100644 --- a/apps/api/app/db/models.py +++ b/apps/api/app/db/models.py @@ -65,6 +65,27 @@ class PlatformCredential(TimestampMixin, Base): last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class ProductionLifecycleRun(TimestampMixin, Base): + __tablename__ = "production_lifecycle_runs" + + id: Mapped[uuid.UUID] = uuid_pk() + project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL")) + status: Mapped[str] = mapped_column(Text, nullable=False) + stage: Mapped[str] = mapped_column(Text, nullable=False) + collection_mode: Mapped[str] = mapped_column(Text, nullable=False) + processing_mode: Mapped[str] = mapped_column(Text, nullable=False) + allow_real_platform_write: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + allow_real_llm: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + allow_real_embedding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + env_preflight: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default="{}") + result_summary: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default="{}") + error_summary: Mapped[str | None] = mapped_column(Text) + rollback_hint: Mapped[str | None] = mapped_column(Text) + redacted_logs: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="[]") + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + class CollectionJob(Base): __tablename__ = "collection_jobs" __table_args__ = ( diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 986b593..1943420 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -2,6 +2,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from app.api.auth import OwnerAuthMiddleware from app.api.errors import ApiError, api_error_handler, http_exception_handler, validation_error_handler from app.api.routes import ( clusters, @@ -10,16 +11,19 @@ keywords, opportunities, processing, + production_runs, projects, reports, settings, signals, ) +from app.config import settings as app_settings app = FastAPI(title="SignalForge API", version="0.1.0-phase-2") +app.add_middleware(OwnerAuthMiddleware) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_origins=app_settings.cors_allow_origins, allow_credentials=False, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], @@ -38,6 +42,7 @@ app.include_router(reports.router) app.include_router(settings.router) app.include_router(processing.router) +app.include_router(production_runs.router) @app.get("/health") diff --git a/apps/api/app/processing/classifier.py b/apps/api/app/processing/classifier.py index 2e53b35..b3408c2 100644 --- a/apps/api/app/processing/classifier.py +++ b/apps/api/app/processing/classifier.py @@ -1,10 +1,17 @@ from __future__ import annotations import json +import os import re from dataclasses import replace -from typing import Any +from typing import Any, Mapping +from app.processing.llm_client import ( + LLMProviderError, + OpenAICompatibleLLMClient, + missing_llm_env, + real_llm_processing_enabled, +) from app.processing.types import ( ALLOWED_SIGNAL_TYPES, SCORE_FIELDS, @@ -41,8 +48,10 @@ def classify_text(text: str, *, mode: str = "mock", llm_payload: Any | None = No safe_text = _safe_text(text) if mode == "fallback_only": return fallback_classify(safe_text, reason="fallback_only") + if mode == "real_llm_classification": + return real_llm_classify(safe_text, llm_payload=llm_payload) if mode != "mock": - raise ValueError("Phase 5 classifier only supports mock and fallback_only modes.") + raise ValueError("Processing classifier only supports mock, fallback_only, and real_llm_classification modes.") if llm_payload is not None: return classify_llm_payload_or_fallback(llm_payload, safe_text) return mock_llm_classify(safe_text) @@ -63,18 +72,68 @@ def mock_llm_classify(text: str) -> ClassificationResult: ) -def classify_llm_payload_or_fallback(payload: Any, fallback_text: str) -> ClassificationResult: +def real_llm_classify( + text: str, + *, + llm_payload: Any | None = None, + env: Mapping[str, str] | None = None, + client: OpenAICompatibleLLMClient | None = None, +) -> ClassificationResult: + safe_text = _safe_text(text) + if llm_payload is not None: + return classify_llm_payload_or_fallback(llm_payload, safe_text, source="real_llm_payload") + if not real_llm_processing_enabled(env): + return fallback_classify( + safe_text, + reason="real_llm_disabled", + counters=ProcessingCounters(llm_json_failure_count=1, fallback_classification_count=1), + metadata={"classification_source": "real_llm_fallback"}, + ) + missing = missing_llm_env(env) + if missing: + return fallback_classify( + safe_text, + reason="real_llm_missing_env", + counters=ProcessingCounters(llm_json_failure_count=1, fallback_classification_count=1), + metadata={"classification_source": "real_llm_fallback", "missing_required_env_count": len(missing)}, + ) try: - return validate_classification_payload(payload, metadata={"classification_source": "llm_payload"}) + active_client = client or _real_llm_client(env) + return classify_llm_payload_or_fallback( + active_client.classify_signal(safe_text), + safe_text, + source="real_llm", + ) + except LLMProviderError as exc: + return fallback_classify( + safe_text, + reason="real_llm_provider_error", + counters=ProcessingCounters(llm_json_failure_count=1, fallback_classification_count=1), + metadata={"classification_source": "real_llm_fallback", "provider_error": _redact_provider_error(str(exc))}, + ) + + +def classify_llm_payload_or_fallback(payload: Any, fallback_text: str, *, source: str = "llm_payload") -> ClassificationResult: + try: + return validate_classification_payload(payload, metadata={"classification_source": source}) except ClassificationValidationError as exc: return fallback_classify( fallback_text, reason="llm_json_invalid", counters=ProcessingCounters(llm_json_failure_count=1, fallback_classification_count=1), - metadata={"validation_error": str(exc)}, + metadata={"validation_error": str(exc), "classification_source": f"{source}_fallback"}, ) +def _real_llm_client(env: Mapping[str, str] | None) -> OpenAICompatibleLLMClient: + source = env or os.environ + return OpenAICompatibleLLMClient( + base_url=source["LLM_BASE_URL"], + api_key=source["LLM_API_KEY"], + model=source["LLM_MODEL"], + ) + + def validate_classification_payload( payload: Any, *, @@ -232,6 +291,13 @@ def _safe_text(text: str) -> str: return re.sub(r"\s+", " ", str(text or "")).strip() +def _redact_provider_error(message: str) -> str: + redacted = str(message or "") + redacted = re.sub(r"(?i)bearer\\s+\\S+", "[REDACTED]", redacted) + redacted = re.sub(r"(?i)(token|secret|api_key|authorization)", "[REDACTED]", redacted) + return redacted + + def _summary_for(signal_type: str, text: str) -> str: excerpt = text[:120].strip() if signal_type == "pricing_issue": diff --git a/apps/api/app/processing/embedding_client.py b/apps/api/app/processing/embedding_client.py index 54ee767..d2f8404 100644 --- a/apps/api/app/processing/embedding_client.py +++ b/apps/api/app/processing/embedding_client.py @@ -1,12 +1,35 @@ from __future__ import annotations import hashlib +import json import math +import os +import urllib.error +import urllib.request from dataclasses import dataclass +from typing import Mapping EMBEDDING_DIMENSION = 1536 MOCK_EMBEDDING_MODEL = "signalforge-mock-embedding-v1" +REAL_EMBEDDING_FLAG = "SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE" +REQUIRED_EMBEDDING_ENV = ("LLM_BASE_URL", "LLM_API_KEY", "EMBEDDING_MODEL") +MAX_EMBEDDING_INPUT_CHARS = 8000 +MAX_EMBEDDING_RESPONSE_BYTES = 262144 + + +class EmbeddingProviderError(RuntimeError): + pass + + +def real_embedding_enabled(env: Mapping[str, str] | None = None) -> bool: + source = env or os.environ + return source.get(REAL_EMBEDDING_FLAG, "").strip().lower() == "true" + + +def missing_embedding_env(env: Mapping[str, str] | None = None) -> list[str]: + source = env or os.environ + return [name for name in REQUIRED_EMBEDDING_ENV if not source.get(name, "").strip()] def _stable_seed(text: str, model_name: str) -> bytes: @@ -45,7 +68,7 @@ def deterministic_mock_embedding( def validate_embedding(vector: list[float], *, dimension: int = EMBEDDING_DIMENSION) -> None: if len(vector) != dimension: raise ValueError(f"embedding must have dimension {dimension}, got {len(vector)}") - if any(not math.isfinite(value) for value in vector): + if any(not isinstance(value, int | float) or isinstance(value, bool) or not math.isfinite(value) for value in vector): raise ValueError("embedding must contain only finite numeric values") @@ -67,3 +90,106 @@ def embed_text(self, text: str) -> EmbeddingResult: def embed_many(self, texts: list[str]) -> list[EmbeddingResult]: return [self.embed_text(text) for text in texts] + + +class OpenAICompatibleEmbeddingClient: + dimension = EMBEDDING_DIMENSION + + def __init__(self, *, base_url: str, api_key: str, model: str, timeout_seconds: float = 15.0) -> None: + self.base_url = base_url.strip().rstrip("/") + self.api_key = api_key + self.model_name = model.strip() + self.timeout_seconds = timeout_seconds + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None, *, timeout_seconds: float = 15.0) -> "OpenAICompatibleEmbeddingClient": + source = env or os.environ + if not real_embedding_enabled(source): + raise EmbeddingProviderError( + "Real embedding is disabled. Set SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE=true to allow a provider call." + ) + missing = missing_embedding_env(source) + if missing: + raise EmbeddingProviderError("Real embedding requires LLM_BASE_URL, LLM_API_KEY, and EMBEDDING_MODEL.") + return cls( + base_url=source["LLM_BASE_URL"], + api_key=source["LLM_API_KEY"], + model=source["EMBEDDING_MODEL"], + timeout_seconds=timeout_seconds, + ) + + def embed_text(self, text: str) -> EmbeddingResult: + safe_text = str(text or "")[:MAX_EMBEDDING_INPUT_CHARS] + payload = { + "model": self.model_name, + "input": safe_text, + } + request = urllib.request.Request( + _embeddings_url(self.base_url), + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response: # noqa: S310 - gated real embedding path. + status_code = int(getattr(response, "status", 0) or 0) + body = response.read(MAX_EMBEDDING_RESPONSE_BYTES).decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + raise EmbeddingProviderError(f"Embedding provider HTTP {exc.code}: {_safe_excerpt(exc.read(512))}") from exc + except Exception as exc: + raise EmbeddingProviderError(_redact(str(exc))) from exc + + if not 200 <= status_code < 300: + raise EmbeddingProviderError(f"Embedding provider HTTP {status_code}: {_safe_excerpt(body)}") + embedding = _embedding_from_response(body) + try: + validate_embedding(embedding, dimension=self.dimension) + except ValueError as exc: + raise EmbeddingProviderError(str(exc)) from exc + return EmbeddingResult(text=text, embedding=embedding, model_name=self.model_name) + + def embed_many(self, texts: list[str]) -> list[EmbeddingResult]: + return [self.embed_text(text) for text in texts] + + +def _embeddings_url(base_url: str) -> str: + if base_url.endswith("/embeddings"): + return base_url + if base_url.endswith("/v1"): + return f"{base_url}/embeddings" + return f"{base_url}/v1/embeddings" + + +def _embedding_from_response(body: str) -> list[float]: + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise EmbeddingProviderError("Embedding provider returned invalid JSON response") from exc + + data = payload.get("data") if isinstance(payload, Mapping) else None + if not isinstance(data, list) or not data: + raise EmbeddingProviderError("Embedding provider response missing data") + first = data[0] + if not isinstance(first, Mapping): + raise EmbeddingProviderError("Embedding provider response item is invalid") + embedding = first.get("embedding") + if not isinstance(embedding, list): + raise EmbeddingProviderError("Embedding provider response missing embedding") + return embedding + + +def _safe_excerpt(value: bytes | str) -> str: + text = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value + return _redact(text)[:500] + + +def _redact(text: str) -> str: + redacted = str(text or "") + for marker in ("Bearer ", "token", "secret", "api_key", "authorization"): + redacted = redacted.replace(marker, "[REDACTED]") + redacted = redacted.replace(marker.upper(), "[REDACTED]") + return redacted diff --git a/apps/api/app/processing/llm_client.py b/apps/api/app/processing/llm_client.py index 9f95427..6567e9c 100644 --- a/apps/api/app/processing/llm_client.py +++ b/apps/api/app/processing/llm_client.py @@ -9,7 +9,10 @@ LLM_SMOKE_FLAG = "SIGNALFORGE_ALLOW_REAL_LLM_SMOKE" +LLM_PROCESSING_FLAG = "SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING" REQUIRED_LLM_ENV = ("LLM_BASE_URL", "LLM_API_KEY", "LLM_MODEL") +MAX_CLASSIFICATION_INPUT_CHARS = 4000 +MAX_CLASSIFICATION_RESPONSE_BYTES = 65536 @dataclass(frozen=True) @@ -19,11 +22,20 @@ class ManualLLMSmokeResult: details: dict[str, Any] +class LLMProviderError(RuntimeError): + pass + + def real_llm_smoke_enabled(env: Mapping[str, str] | None = None) -> bool: source = env or os.environ return source.get(LLM_SMOKE_FLAG, "").strip().lower() == "true" +def real_llm_processing_enabled(env: Mapping[str, str] | None = None) -> bool: + source = env or os.environ + return source.get(LLM_PROCESSING_FLAG, "").strip().lower() == "true" + + def missing_llm_env(env: Mapping[str, str] | None = None) -> list[str]: source = env or os.environ return [name for name in REQUIRED_LLM_ENV if not source.get(name, "").strip()] @@ -108,6 +120,36 @@ def smoke(self) -> ManualLLMSmokeResult: details={"http_status": status_code, "safe_body_excerpt": _safe_excerpt(body)}, ) + def classify_signal(self, text: str) -> str: + payload = { + "model": self.model, + "messages": _classification_messages(text), + "temperature": 0, + "max_tokens": 500, + } + request = urllib.request.Request( + _chat_completions_url(self.base_url), + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response: # noqa: S310 - gated real LLM path. + status_code = int(getattr(response, "status", 0) or 0) + body = response.read(MAX_CLASSIFICATION_RESPONSE_BYTES).decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + raise LLMProviderError(f"LLM provider HTTP {exc.code}: {_safe_excerpt(exc.read(512))}") from exc + except Exception as exc: + raise LLMProviderError(_redact(str(exc))) from exc + + if not 200 <= status_code < 300: + raise LLMProviderError(f"LLM provider HTTP {status_code}: {_safe_excerpt(body)}") + return _chat_message_content(body) + def _chat_completions_url(base_url: str) -> str: if base_url.endswith("/chat/completions"): @@ -117,6 +159,63 @@ def _chat_completions_url(base_url: str) -> str: return f"{base_url}/v1/chat/completions" +def _classification_messages(text: str) -> list[dict[str, str]]: + safe_text = str(text or "")[:MAX_CLASSIFICATION_INPUT_CHARS] + schema = { + "is_need_signal": True, + "signal_type": "workflow_pain", + "pain_level": 75, + "clarity_score": 75, + "urgency_score": 70, + "business_relevance": 75, + "model_confidence": 70, + "signal_confidence": 72, + "summary_zh": "一句中文摘要。", + "recommended_action": "A short actionable recommendation.", + } + return [ + { + "role": "system", + "content": ( + "You classify user demand signals for a local product opportunity radar. " + "Return only one JSON object. Do not include markdown. " + "signal_type must be one of: complaint, feature_request, alternative_search, " + "pricing_issue, security_concern, workflow_pain, integration_need, " + "learning_barrier, positive_feedback, noise. " + "All score fields must be integers from 0 to 100." + ), + }, + { + "role": "user", + "content": ( + "Classify this source text into the exact JSON schema below.\n" + f"Schema example: {json.dumps(schema, ensure_ascii=False)}\n" + f"Source text:\n{safe_text}" + ), + }, + ] + + +def _chat_message_content(body: str) -> str: + try: + payload = json.loads(body) + choices = payload.get("choices") + if not isinstance(choices, list) or not choices: + raise LLMProviderError("LLM provider response missing choices") + first = choices[0] + if not isinstance(first, Mapping): + raise LLMProviderError("LLM provider response choice is invalid") + message = first.get("message") + if not isinstance(message, Mapping): + raise LLMProviderError("LLM provider response missing message") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + raise LLMProviderError("LLM provider response missing content") + return content.strip() + except json.JSONDecodeError as exc: + raise LLMProviderError("LLM provider returned invalid JSON response") from exc + + def _safe_excerpt(value: bytes | str) -> str: text = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value return _redact(text)[:500] diff --git a/apps/api/app/processing/pipeline.py b/apps/api/app/processing/pipeline.py index 6823a74..3a52db2 100644 --- a/apps/api/app/processing/pipeline.py +++ b/apps/api/app/processing/pipeline.py @@ -10,7 +10,12 @@ from app.processing import prepare_text_for_processing from app.processing.classifier import classify_text from app.processing.clustering import assign_signal_to_cluster -from app.processing.embedding_client import MOCK_EMBEDDING_MODEL, MockEmbeddingClient +from app.processing.embedding_client import ( + EmbeddingProviderError, + MockEmbeddingClient, + OpenAICompatibleEmbeddingClient, + validate_embedding, +) from app.processing.opportunity_scoring import upsert_opportunity_from_cluster from app.processing.signal_quality import build_signal_quality_summary from app.processing.types import ClassificationResult, ProcessingCounters, clamp_score @@ -19,6 +24,7 @@ HIGH_VALUE_PAIN_LEVEL = 70 HIGH_VALUE_CONFIDENCE = 60 +SUPPORTED_PROCESSING_MODES = {"mock", "fallback_only", "real_llm_classification", "real_embedding"} def _raw_items_for_project(db: Session, project_id: UUID) -> list[RawItem]: @@ -91,14 +97,15 @@ def _upsert_signal(db: Session, raw_item: RawItem, classification: Classificatio return signal -def _upsert_embedding(db: Session, signal: Signal, vector: list[float]) -> Embedding: +def _upsert_embedding(db: Session, signal: Signal, vector: list[float], *, model_name: str) -> Embedding: + validate_embedding(vector) embedding = db.scalar(select(Embedding).where(Embedding.signal_id == signal.id)) if embedding is None: - embedding = Embedding(signal_id=signal.id, model_name=MOCK_EMBEDDING_MODEL, embedding=vector) + embedding = Embedding(signal_id=signal.id, model_name=model_name, embedding=vector) db.add(embedding) else: embedding.embedding = vector - embedding.model_name = MOCK_EMBEDDING_MODEL + embedding.model_name = model_name db.flush() return embedding @@ -175,12 +182,13 @@ def process_project_raw_items( reprocess: bool = False, force_invalid_llm_json: bool = False, ) -> dict[str, Any]: - if mode not in {"mock", "fallback_only"}: - raise ValueError("Phase 5 processing only supports mock and fallback_only modes.") + if mode not in SUPPORTED_PROCESSING_MODES: + raise ValueError("Processing pipeline only supports mock, fallback_only, real_llm_classification, and real_embedding modes.") get_or_404(db, Project, project_id, "Project") raw_items = _raw_items_for_project(db, project_id) - embedding_client = MockEmbeddingClient() + embedding_client = OpenAICompatibleEmbeddingClient.from_env() if mode == "real_embedding" else MockEmbeddingClient() + classification_mode = "fallback_only" if mode == "real_embedding" else mode processed_in_run = 0 skipped_existing = 0 @@ -200,7 +208,7 @@ def process_project_raw_items( classification = _classification_for_raw_item( raw_item, prepared.redacted_text, - mode=mode, + mode=classification_mode, force_invalid_llm_json=force_invalid_llm_json, ) fallback_count += classification.counters.fallback_classification_count @@ -212,8 +220,14 @@ def process_project_raw_items( processed_in_run += 1 continue - vector = embedding_client.embed_text(prepared.redacted_text).embedding - _upsert_embedding(db, signal, vector) + embedding_result = embedding_client.embed_text(prepared.redacted_text) + vector = embedding_result.embedding + if mode == "real_embedding": + try: + validate_embedding(vector) + except ValueError as exc: + raise EmbeddingProviderError(str(exc)) from exc + _upsert_embedding(db, signal, vector, model_name=embedding_result.model_name) cluster, _, _ = assign_signal_to_cluster(db, signal=signal, embedding=vector) upsert_opportunity_from_cluster(db, cluster) processed_in_run += 1 diff --git a/apps/api/app/schemas/production_runs.py b/apps/api/app/schemas/production_runs.py new file mode 100644 index 0000000..a6efa2d --- /dev/null +++ b/apps/api/app/schemas/production_runs.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class ProductionRunCreate(ApiSchema): + project_id: UUID | None = None + collection_mode: str = "mock" + processing_mode: str = "fallback_only" + reprocess: bool = False + allow_real_platform_write: bool = False + allow_real_llm: bool = False + allow_real_embedding: bool = False + execute: bool = True + rollback_hint: str | None = None + redacted_logs: list[dict[str, Any]] | None = None + + +class ProductionRunCloseout(ApiSchema): + status: str = Field(default="closed", min_length=1) + result_summary: dict[str, Any] | None = None + error_summary: str | None = None + rollback_hint: str | None = None + redacted_logs: list[dict[str, Any]] | None = None + + +class ProductionRunRead(ApiSchema): + id: UUID + project_id: UUID | None = None + status: str + stage: str + collection_mode: str + processing_mode: str + allow_real_platform_write: bool + allow_real_llm: bool + allow_real_embedding: bool + env_preflight: dict[str, Any] + result_summary: dict[str, Any] + error_summary: str | None = None + rollback_hint: str | None = None + redacted_logs: list[dict[str, Any]] + started_at: datetime | None = None + finished_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/apps/api/app/schemas/settings.py b/apps/api/app/schemas/settings.py index b8a2229..ca33d40 100644 --- a/apps/api/app/schemas/settings.py +++ b/apps/api/app/schemas/settings.py @@ -3,12 +3,24 @@ from datetime import datetime from typing import Literal +from pydantic import Field + from app.schemas.common import ApiSchema PlatformName = Literal["reddit", "product_hunt", "x", "discord"] PlatformPhase = Literal["P0", "P1", "P2"] CredentialStatus = Literal["missing", "configured", "invalid", "permission_limited", "disabled"] +PlatformTestStatus = Literal[ + "available", + "missing_env", + "configured_unverified", + "valid", + "invalid", + "rate_limited", + "permission_limited", + "coming_soon", +] class PlatformStatus(ApiSchema): @@ -31,3 +43,11 @@ class CredentialStatusItem(ApiSchema): class CredentialStatusResponse(ApiSchema): credentials: list[CredentialStatusItem] + + +class PlatformEnvTestResponse(ApiSchema): + platform: str + status: PlatformTestStatus + message: str + checked_at: datetime + required_env_missing: list[str] = Field(default_factory=list) diff --git a/apps/api/app/services/production_runs.py b/apps/api/app/services/production_runs.py new file mode 100644 index 0000000..c7eb682 --- /dev/null +++ b/apps/api/app/services/production_runs.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import os +from datetime import UTC, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.api.auth import env_flag_enabled +from app.connectors.credential_resolver import PRODUCT_HUNT_ENV_VARS, REAL_PLATFORM_SMOKE_ENV, REDDIT_ENV_VARS +from app.db.models import CollectionLog, ProductionLifecycleRun, Project +from app.processing.embedding_client import REAL_EMBEDDING_FLAG, REQUIRED_EMBEDDING_ENV +from app.processing.llm_client import LLM_PROCESSING_FLAG, REQUIRED_LLM_ENV +from app.schemas.common import PaginationParams +from app.schemas.production_runs import ProductionRunCloseout, ProductionRunCreate +from app.services import processing_pipeline +from app.services.collection_executor import execute_collection +from app.services.common import commit_and_refresh, get_or_404, paginate + + +SAFE_COLLECTION_MODES = {"mock", "disabled_only", "safe_disabled"} +SAFE_PROCESSING_MODES = {"mock", "fallback_only"} +REAL_LLM_PROCESSING_MODES = {"real_llm_classification"} +REAL_EMBEDDING_PROCESSING_MODES = {"real_embedding"} +SENSITIVE_KEY_PARTS = ("token", "secret", "password", "credential", "authorization", "cookie", "key") +REDACTED = "[REDACTED]" + + +def list_runs(db: Session, pagination: PaginationParams) -> tuple[list[ProductionLifecycleRun], int]: + stmt = select(ProductionLifecycleRun).order_by( + ProductionLifecycleRun.created_at.desc(), + ProductionLifecycleRun.id.desc(), + ) + return paginate(db, stmt, pagination) + + +def get_run(db: Session, run_id: UUID | str) -> ProductionLifecycleRun: + return get_or_404(db, ProductionLifecycleRun, run_id, "Production lifecycle run") + + +def create_run(db: Session, payload: ProductionRunCreate) -> ProductionLifecycleRun: + if payload.project_id is not None: + get_or_404(db, Project, payload.project_id, "Project") + + preflight = _preflight(payload) + lifecycle = [_lifecycle_event("preflight", "pass" if preflight["safe_execution"] else "no_go")] + now = datetime.now(UTC) + run = ProductionLifecycleRun( + project_id=payload.project_id, + status="running", + stage="preflight", + collection_mode=payload.collection_mode, + processing_mode=payload.processing_mode, + allow_real_platform_write=payload.allow_real_platform_write, + allow_real_llm=payload.allow_real_llm, + allow_real_embedding=payload.allow_real_embedding, + env_preflight=preflight, + result_summary={"lifecycle": lifecycle}, + rollback_hint=_redact(payload.rollback_hint), + redacted_logs=_redact(payload.redacted_logs or []), + started_at=now, + ) + db.add(run) + db.commit() + db.refresh(run) + + if preflight["safe_execution"] is not True: + run.status = "no_go_real_provider" + run.stage = "preflight" + run.error_summary = "Real provider mode blocked by missing approval or environment flags." + run.result_summary = _redact({"lifecycle": lifecycle}) + run.finished_at = datetime.now(UTC) + return commit_and_refresh(db, run) + + if payload.project_id is None: + run.status = "preflight_only" + run.stage = "preflight" + run.result_summary = _redact({"lifecycle": lifecycle}) + run.finished_at = datetime.now(UTC) + return commit_and_refresh(db, run) + + if not payload.execute: + run.status = "preflight_passed" + run.stage = "preflight" + run.result_summary = _redact({"lifecycle": lifecycle}) + run.finished_at = datetime.now(UTC) + return commit_and_refresh(db, run) + + try: + lifecycle.append(_lifecycle_event("collect", "running")) + run.stage = "collect" + collection_job = execute_collection( + db, + project_id=payload.project_id, + execution_mode=payload.collection_mode, + ) + collection_log = db.scalar( + select(CollectionLog) + .where(CollectionLog.job_id == collection_job.id) + .order_by(CollectionLog.created_at.desc(), CollectionLog.id.desc()) + .limit(1) + ) + lifecycle[-1] = _lifecycle_event( + "collect", + collection_job.status, + { + "job_id": str(collection_job.id), + "items_collected": collection_log.items_collected if collection_log else 0, + "items_inserted": collection_log.items_inserted if collection_log else 0, + "items_skipped": collection_log.items_skipped if collection_log else 0, + }, + ) + collection_summary = { + "job_id": str(collection_job.id), + "status": collection_job.status, + "items_collected": collection_log.items_collected if collection_log else 0, + "items_inserted": collection_log.items_inserted if collection_log else 0, + "items_skipped": collection_log.items_skipped if collection_log else 0, + } + if collection_job.status != "success": + run.status = "failed" + run.stage = "collect" + run.error_summary = collection_job.error_summary or f"Collection finished with status {collection_job.status}." + run.result_summary = _redact( + { + "lifecycle": lifecycle, + "collection": collection_summary, + } + ) + run.finished_at = datetime.now(UTC) + return commit_and_refresh(db, run) + + lifecycle.append(_lifecycle_event("process", "running")) + run.stage = "process" + processing_summary = processing_pipeline.process_project( + db=db, + project_id=payload.project_id, + mode=payload.processing_mode, + reprocess=payload.reprocess, + ) + lifecycle[-1] = _lifecycle_event("process", "success", processing_summary) + lifecycle.append(_lifecycle_event("review", "ready")) + lifecycle.append( + _lifecycle_event( + "report", + "ready", + { + "report_surface": "reports page", + "closeout_required": True, + }, + ) + ) + lifecycle.append( + _lifecycle_event( + "closeout", + "success", + { + "rollback_hint": "Use the stored rollback_hint and local production restore runbook if needed.", + "real_provider_status": ( + "NO_GO_REAL_PROVIDER" + if preflight["real_collection_requested"] or preflight["real_processing_requested"] + else "NOT_REQUESTED" + ), + }, + ) + ) + except Exception as exc: + db.rollback() + run = get_run(db, run.id) + run.status = "failed" + run.result_summary = _redact({"lifecycle": lifecycle}) + run.error_summary = _redact(str(exc)) + run.finished_at = datetime.now(UTC) + return commit_and_refresh(db, run) + + run.status = "success" + run.stage = "closeout" + run.result_summary = _redact( + { + "lifecycle": lifecycle, + "collection": { + **collection_summary, + }, + "processing": processing_summary, + } + ) + run.finished_at = datetime.now(UTC) + return commit_and_refresh(db, run) + + +def closeout_run(db: Session, run_id: UUID | str, payload: ProductionRunCloseout) -> ProductionLifecycleRun: + run = get_run(db, run_id) + run.status = payload.status + run.stage = "closeout" + result_summary = dict(run.result_summary or {}) + lifecycle = result_summary.get("lifecycle") + if not isinstance(lifecycle, list): + lifecycle = [] + lifecycle.append(_lifecycle_event("closeout", payload.status)) + result_summary["lifecycle"] = lifecycle + if payload.result_summary is not None: + result_summary.update(payload.result_summary) + run.result_summary = _redact(result_summary) + flag_modified(run, "result_summary") + if payload.error_summary is not None: + run.error_summary = _redact(payload.error_summary) + if payload.rollback_hint is not None: + run.rollback_hint = _redact(payload.rollback_hint) + if payload.redacted_logs is not None: + run.redacted_logs = _redact(payload.redacted_logs) + run.finished_at = datetime.now(UTC) + return commit_and_refresh(db, run) + + +def _preflight(payload: ProductionRunCreate) -> dict[str, Any]: + missing: list[str] = [] + blocked: list[str] = [] + real_collection = payload.collection_mode not in SAFE_COLLECTION_MODES + real_processing = payload.processing_mode not in SAFE_PROCESSING_MODES + real_llm_processing = payload.processing_mode in REAL_LLM_PROCESSING_MODES + real_embedding_processing = payload.processing_mode in REAL_EMBEDDING_PROCESSING_MODES + + if real_collection: + if not payload.allow_real_platform_write: + blocked.append("allow_real_platform_write") + if not env_flag_enabled(REAL_PLATFORM_SMOKE_ENV): + missing.append(REAL_PLATFORM_SMOKE_ENV) + if not env_flag_enabled("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE"): + missing.append("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE") + missing.extend(_missing_collection_env(payload.collection_mode)) + if real_processing: + if not real_llm_processing and not real_embedding_processing: + blocked.append("unsupported_processing_mode") + if real_llm_processing and not payload.allow_real_llm: + blocked.append("allow_real_llm") + if real_embedding_processing and not payload.allow_real_embedding: + blocked.append("allow_real_embedding") + if real_llm_processing and not env_flag_enabled(LLM_PROCESSING_FLAG): + missing.append(LLM_PROCESSING_FLAG) + if real_embedding_processing and not env_flag_enabled(REAL_EMBEDDING_FLAG): + missing.append(REAL_EMBEDDING_FLAG) + if real_llm_processing: + missing.extend(name for name in REQUIRED_LLM_ENV if not os.getenv(name, "").strip()) + if real_embedding_processing: + missing.extend(name for name in REQUIRED_EMBEDDING_ENV if not os.getenv(name, "").strip()) + + return { + "safe_execution": not missing and not blocked, + "collection_mode": payload.collection_mode, + "processing_mode": payload.processing_mode, + "real_collection_requested": real_collection, + "real_processing_requested": real_processing, + "missing": missing, + "blocked": blocked, + "checked_env": [ + REAL_PLATFORM_SMOKE_ENV, + "SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", + LLM_PROCESSING_FLAG, + REAL_EMBEDDING_FLAG, + ], + } + + +def _missing_collection_env(collection_mode: str) -> list[str]: + modes = {collection_mode} + if collection_mode == "p0_real": + modes = {"reddit", "product_hunt"} + + required: list[str] = [] + if "reddit" in modes: + required.extend(REDDIT_ENV_VARS) + if "product_hunt" in modes: + required.extend(PRODUCT_HUNT_ENV_VARS) + + return [name for name in dict.fromkeys(required) if not os.getenv(name, "").strip()] + + +def _lifecycle_event(stage: str, status: str, details: dict[str, Any] | None = None) -> dict[str, Any]: + event: dict[str, Any] = { + "stage": stage, + "status": status, + "recorded_at": datetime.now(UTC).isoformat(), + } + if details: + event["details"] = _redact(details) + return event + + +def _redact(value: Any) -> Any: + if isinstance(value, dict): + redacted: dict[str, Any] = {} + for key, item in value.items(): + if _sensitive_key(str(key)): + redacted[key] = REDACTED + else: + redacted[key] = _redact(item) + return redacted + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, tuple): + return [_redact(item) for item in value] + if isinstance(value, str): + redacted_value = value + for env_name, env_value in os.environ.items(): + if _sensitive_key(env_name) and env_value and len(env_value) >= 4: + redacted_value = redacted_value.replace(env_value, REDACTED) + if any(marker in redacted_value.lower() for marker in ("token=", "secret=", "authorization:", "bearer ")): + return REDACTED + return redacted_value + return value + + +def _sensitive_key(key: str) -> bool: + lowered = key.lower() + return any(part in lowered for part in SENSITIVE_KEY_PARTS) diff --git a/apps/api/app/services/reports.py b/apps/api/app/services/reports.py index d9c07dd..16d0677 100644 --- a/apps/api/app/services/reports.py +++ b/apps/api/app/services/reports.py @@ -5,10 +5,10 @@ from datetime import datetime, timezone from uuid import UUID -from sqlalchemy import desc, select +from sqlalchemy import desc, func, select from sqlalchemy.orm import Session -from app.db.models import Cluster, Opportunity, Project, RawItem, Signal +from app.db.models import Cluster, CollectionJob, CollectionLog, Keyword, Opportunity, Project, RawItem, Signal from app.schemas.reports import ReportRequest from app.services.common import get_or_404 @@ -45,18 +45,82 @@ def _opportunities(db: Session, project_id: UUID, limit: int) -> list[Opportunit return list(db.scalars(stmt).all()) +def _keywords(db: Session, project_id: UUID) -> list[Keyword]: + return list( + db.scalars( + select(Keyword) + .where(Keyword.project_id == project_id) + .order_by(Keyword.keyword_type.asc(), Keyword.created_at.asc(), Keyword.id.asc()) + ) + ) + + +def _collection_stats(db: Session, project_id: UUID) -> dict[str, int]: + logs = list( + db.scalars( + select(CollectionLog) + .join(CollectionJob, CollectionJob.id == CollectionLog.job_id) + .where(CollectionJob.project_id == project_id) + .order_by(CollectionLog.created_at.desc()) + .limit(20) + ) + ) + raw_items = int( + db.scalar(select(func.count()).select_from(RawItem).where(RawItem.project_id == project_id)) + or 0 + ) + return { + "recent_items_collected": sum(log.items_collected for log in logs), + "recent_items_inserted": sum(log.items_inserted for log in logs), + "raw_items": raw_items, + } + + +def _processing_stats(db: Session, project_id: UUID) -> dict[str, int]: + return { + "signals": len(list(db.scalars(select(Signal.id).where(Signal.project_id == project_id)))), + "opportunities": len(list(db.scalars(select(Opportunity.id).where(Opportunity.project_id == project_id)))), + "clusters": len(list(db.scalars(select(Cluster.id).where(Cluster.project_id == project_id)))), + } + + def generate_markdown_report(db: Session, project_id: UUID, request: ReportRequest) -> str: project = get_or_404(db, Project, project_id, "Project") signals = _high_value_signals(db, project_id, request.min_pain_level) clusters = _top_clusters(db, project_id, request.top_clusters_limit) opportunities = _opportunities(db, project_id, request.opportunities_limit) + keywords = _keywords(db, project_id) + collection_stats = _collection_stats(db, project_id) + processing_stats = _processing_stats(db, project_id) + keyword_lines = [ + f"- {keyword.keyword_type}: {keyword.keyword}" + for keyword in keywords + ] or ["- No keywords configured."] lines = [ f"# SignalForge Report: {project.name}", "", f"Generated at: {datetime.now(timezone.utc).isoformat()}", + "Mode: mock / fallback_only / real status is recorded per workflow; Personal Production v1 validates mock and fallback_only.", f"High value threshold: pain_level >= {request.min_pain_level}", "", + "## Project", + f"- Name: {project.name}", + f"- Description: {project.description or 'Not provided'}", + "", + "## Keywords", + *keyword_lines, + "", + "## Collection Stats", + f"- raw_items: {collection_stats['raw_items']}", + f"- recent_items_collected: {collection_stats['recent_items_collected']}", + f"- recent_items_inserted: {collection_stats['recent_items_inserted']}", + "", + "## Processing Stats", + f"- signals: {processing_stats['signals']}", + f"- clusters: {processing_stats['clusters']}", + f"- opportunities: {processing_stats['opportunities']}", + "", "## High Value Signals", ] @@ -67,7 +131,7 @@ def generate_markdown_report(db: Session, project_id: UUID, request: ReportReque "- " f"[{raw_item.platform}] Pain {signal.pain_level}: " f"{signal.summary_zh or raw_item.content_excerpt or raw_item.content_text or 'No summary'} " - f"(source_url: {raw_item.source_url})" + f"(source_url: {raw_item.source_url}; recommended_action: {signal.recommended_action or 'Review manually.'})" ) lines.extend(["", "## Top Clusters"]) @@ -87,7 +151,8 @@ def generate_markdown_report(db: Session, project_id: UUID, request: ReportReque lines.append( "- " f"{opportunity.title} | status: {opportunity.status} | " - f"score: {opportunity.opportunity_score} | evidence_count: {opportunity.evidence_count}" + f"score: {opportunity.opportunity_score} | evidence_count: {opportunity.evidence_count} | " + f"suggested_next_action: {opportunity.description or 'Review evidence and pick the next action.'}" ) return "\n".join(lines) + "\n" @@ -106,7 +171,9 @@ def generate_csv_report(db: Session, project_id: UUID, request: ReportRequest) - "signal_type", "pain_level", "summary_zh", + "recommended_action", "source_url", + "mode", "created_at", ], ) @@ -120,7 +187,9 @@ def generate_csv_report(db: Session, project_id: UUID, request: ReportRequest) - "signal_type": signal.signal_type or "", "pain_level": signal.pain_level if signal.pain_level is not None else "", "summary_zh": signal.summary_zh or "", + "recommended_action": signal.recommended_action or "", "source_url": raw_item.source_url, + "mode": "mock/fallback_only", "created_at": signal.created_at.isoformat() if signal.created_at else "", } ) diff --git a/apps/api/app/services/settings.py b/apps/api/app/services/settings.py index 1eb8985..c950ece 100644 --- a/apps/api/app/services/settings.py +++ b/apps/api/app/services/settings.py @@ -1,10 +1,15 @@ from __future__ import annotations +import os +from collections.abc import Mapping +from datetime import UTC, datetime + from sqlalchemy import select from sqlalchemy.orm import Session +from app.connectors.credential_resolver import PRODUCT_HUNT_ENV_VARS, REDDIT_ENV_VARS from app.db.models import PlatformCredential -from app.schemas.settings import CredentialStatusItem, PlatformStatus +from app.schemas.settings import CredentialStatusItem, PlatformEnvTestResponse, PlatformStatus PLATFORM_PHASES = { @@ -16,6 +21,15 @@ ALLOWED_STATUSES = {"missing", "configured", "invalid", "permission_limited", "disabled"} ACTIVE_STATUSES = {"active", "configured"} +REQUIRED_ENV_BY_PLATFORM = { + "reddit": REDDIT_ENV_VARS, + "product_hunt": PRODUCT_HUNT_ENV_VARS, +} +PLATFORM_ALIASES = { + "product-hunt": "product_hunt", + "producthunt": "product_hunt", + "twitter": "x", +} def normalize_credential_status(status: str | None) -> str: @@ -40,12 +54,15 @@ def list_platform_statuses(db: Session) -> list[PlatformStatus]: statuses: list[PlatformStatus] = [] for platform, (phase, enabled_for_mvp) in PLATFORM_PHASES.items(): credential = credentials.get(platform) + status = normalize_credential_status(credential.status if credential else None) + if credential is None: + status = env_backed_platform_status(platform) statuses.append( PlatformStatus( platform=platform, # type: ignore[arg-type] phase=phase, # type: ignore[arg-type] enabled_for_mvp=enabled_for_mvp, - status=normalize_credential_status(credential.status if credential else None), # type: ignore[arg-type] + status=status, # type: ignore[arg-type] ) ) return statuses @@ -56,12 +73,76 @@ def list_credential_statuses(db: Session) -> list[CredentialStatusItem]: items: list[CredentialStatusItem] = [] for platform in PLATFORM_PHASES: credential = credentials.get(platform) + status = normalize_credential_status(credential.status if credential else None) + if credential is None: + status = env_backed_platform_status(platform) items.append( CredentialStatusItem( platform=platform, # type: ignore[arg-type] - status=normalize_credential_status(credential.status if credential else None), # type: ignore[arg-type] + status=status, # type: ignore[arg-type] credential_name=credential.credential_name if credential else None, last_checked_at=credential.last_checked_at if credential else None, ) ) return items + + +def env_backed_platform_status(platform: str, env: Mapping[str, str] | None = None) -> str: + _phase, enabled_for_mvp = PLATFORM_PHASES[platform] + if not enabled_for_mvp: + return "disabled" + + required_env = REQUIRED_ENV_BY_PLATFORM.get(platform, ()) + source_env = os.environ if env is None else env + return "configured" if required_env and all(source_env.get(name, "").strip() for name in required_env) else "missing" + + +def test_platform_env_status(platform: str, env: Mapping[str, str] | None = None) -> PlatformEnvTestResponse: + normalized_platform = normalize_platform_name(platform) + if normalized_platform == "mock": + return PlatformEnvTestResponse( + platform=normalized_platform, + status="available", + message="Mock connector is always available for Personal Production v1.", + checked_at=datetime.now(UTC), + ) + if normalized_platform not in PLATFORM_PHASES: + raise ValueError(f"unsupported platform: {platform}") + + checked_at = datetime.now(UTC) + _phase, enabled_for_mvp = PLATFORM_PHASES[normalized_platform] + if not enabled_for_mvp: + return PlatformEnvTestResponse( + platform=normalized_platform, # type: ignore[arg-type] + status="coming_soon", + message=f"{normalized_platform} environment test is not available in the current phase.", + checked_at=checked_at, + ) + + required_env = REQUIRED_ENV_BY_PLATFORM.get(normalized_platform, ()) + source_env = os.environ if env is None else env + missing_env = [name for name in required_env if not source_env.get(name, "").strip()] + if missing_env: + return PlatformEnvTestResponse( + platform=normalized_platform, # type: ignore[arg-type] + status="missing_env", + message=f"{normalized_platform} is missing required environment variables.", + checked_at=checked_at, + required_env_missing=missing_env, + ) + + return PlatformEnvTestResponse( + platform=normalized_platform, # type: ignore[arg-type] + status="configured_unverified", + message=( + f"{normalized_platform} required environment variables are configured; " + "live platform verification is not performed by this endpoint." + ), + checked_at=checked_at, + ) + + +def normalize_platform_name(platform: str) -> str: + normalized = platform.strip().lower().replace(" ", "_") + normalized = PLATFORM_ALIASES.get(normalized, normalized) + return normalized diff --git a/apps/api/migrations/versions/0002_production_lifecycle_runs.py b/apps/api/migrations/versions/0002_production_lifecycle_runs.py new file mode 100644 index 0000000..2684df3 --- /dev/null +++ b/apps/api/migrations/versions/0002_production_lifecycle_runs.py @@ -0,0 +1,56 @@ +"""add production lifecycle runs + +Revision ID: 0002_production_lifecycle_runs +Revises: 0001_initial_data_model +Create Date: 2026-05-12 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "0002_production_lifecycle_runs" +down_revision: str | None = "0001_initial_data_model" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +uuid_type = postgresql.UUID(as_uuid=True) + + +def upgrade() -> None: + op.create_table( + "production_lifecycle_runs", + sa.Column("id", uuid_type, nullable=False), + sa.Column("project_id", uuid_type, nullable=True), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("stage", sa.Text(), nullable=False), + sa.Column("collection_mode", sa.Text(), nullable=False), + sa.Column("processing_mode", sa.Text(), nullable=False), + sa.Column("allow_real_platform_write", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("allow_real_llm", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("allow_real_embedding", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("env_preflight", postgresql.JSONB(), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("result_summary", postgresql.JSONB(), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("error_summary", sa.Text(), nullable=True), + sa.Column("rollback_hint", sa.Text(), nullable=True), + sa.Column("redacted_logs", postgresql.JSONB(), server_default=sa.text("'[]'::jsonb"), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + name=op.f("fk_production_lifecycle_runs_project_id_projects"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_production_lifecycle_runs")), + ) + + +def downgrade() -> None: + op.drop_table("production_lifecycle_runs") diff --git a/apps/api/tests/test_collection_api.py b/apps/api/tests/test_collection_api.py index 642f727..2c2d681 100644 --- a/apps/api/tests/test_collection_api.py +++ b/apps/api/tests/test_collection_api.py @@ -10,6 +10,9 @@ from app.main import app +EXPECTED_MOCK_ITEMS = 5 + + def create_project() -> str: assert SessionLocal is not None with SessionLocal() as db: @@ -65,6 +68,7 @@ def clear_p0_env(monkeypatch) -> None: "REDDIT_USER_AGENT", "PRODUCT_HUNT_TOKEN", "SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE", + "SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", ): monkeypatch.delenv(name, raising=False) @@ -83,8 +87,8 @@ def test_collect_mock_executes_and_inserts_raw_items() -> None: assert payload["collector_execution"] == "mock" assert payload["log"]["platform"] == "mock" assert payload["log"]["status"] == "success" - assert payload["log"]["items_collected"] == 3 - assert raw_item_count(project_id) > before_count + assert payload["log"]["items_collected"] == EXPECTED_MOCK_ITEMS + assert raw_item_count(project_id) == before_count + EXPECTED_MOCK_ITEMS finally: delete_project(project_id) @@ -154,6 +158,28 @@ def test_collect_p0_modes_degrade_without_credentials(monkeypatch) -> None: delete_project(project_id) +def test_collect_real_platform_smoke_requires_write_gate(monkeypatch) -> None: + clear_p0_env(monkeypatch) + monkeypatch.setenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE", "true") + monkeypatch.setenv("PRODUCT_HUNT_TOKEN", "ph-secret-value") + project_id = create_project() + try: + before_count = raw_item_count(project_id) + response = TestClient(app).post( + f"/api/projects/{project_id}/collect", + json={"execution_mode": "product_hunt"}, + ) + payload = response.json() + + assert response.status_code == 409 + assert payload["error"]["code"] == "phase_not_available" + assert payload["error"]["message"] == "Real platform collection write requires SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE=true." + assert "ph-secret-value" not in response.text + assert raw_item_count(project_id) == before_count + finally: + delete_project(project_id) + + def test_collect_future_and_forbidden_modes_return_phase_not_available() -> None: project_id = create_project() try: diff --git a/apps/api/tests/test_collection_executor.py b/apps/api/tests/test_collection_executor.py index da2c2cb..2ee6257 100644 --- a/apps/api/tests/test_collection_executor.py +++ b/apps/api/tests/test_collection_executor.py @@ -19,6 +19,9 @@ from app.services.collection_executor import execute_collection +EXPECTED_MOCK_ITEMS = 5 + + class DuplicateConnector(BaseConnector): platform = "duplicate_test" @@ -143,12 +146,12 @@ def test_mock_connector_job_inserts_raw_items_and_success_log() -> None: log = db.scalar(select(CollectionLog).where(CollectionLog.job_id == job.id)) assert job.status == "success" - assert raw_count == 3 + assert raw_count == EXPECTED_MOCK_ITEMS assert log is not None assert log.platform == "mock" assert log.status == "success" - assert log.items_collected == 3 - assert log.items_inserted == 3 + assert log.items_collected == EXPECTED_MOCK_ITEMS + assert log.items_inserted == EXPECTED_MOCK_ITEMS assert log.items_skipped == 0 finally: _delete_project(project.id) diff --git a/apps/api/tests/test_cors_config.py b/apps/api/tests/test_cors_config.py new file mode 100644 index 0000000..48b3adb --- /dev/null +++ b/apps/api/tests/test_cors_config.py @@ -0,0 +1,28 @@ +from app.config import DEFAULT_CORS_ALLOW_ORIGINS, parse_cors_allow_origins, settings +from app.main import app + + +def test_cors_allow_origins_parser_default() -> None: + assert parse_cors_allow_origins(None) == DEFAULT_CORS_ALLOW_ORIGINS + + +def test_cors_allow_origins_parser_empty_value_uses_default() -> None: + assert parse_cors_allow_origins(" , , /// ") == DEFAULT_CORS_ALLOW_ORIGINS + + +def test_cors_allow_origins_parser_trims_spaces_filters_empty_and_removes_trailing_slash() -> None: + assert parse_cors_allow_origins(" http://localhost:3000/ , , http://127.0.0.1:3000/// ") == ( + "http://localhost:3000", + "http://127.0.0.1:3000", + ) + + +def test_main_cors_allow_origins_does_not_include_wildcard() -> None: + cors_middleware = next( + middleware + for middleware in app.user_middleware + if middleware.cls.__name__ == "CORSMiddleware" + ) + + assert cors_middleware.kwargs["allow_origins"] == settings.cors_allow_origins + assert "*" not in cors_middleware.kwargs["allow_origins"] diff --git a/apps/api/tests/test_owner_auth.py b/apps/api/tests/test_owner_auth.py new file mode 100644 index 0000000..4ccb689 --- /dev/null +++ b/apps/api/tests/test_owner_auth.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + + +def test_owner_auth_disabled_by_default_allows_api_without_token(monkeypatch) -> None: + monkeypatch.delenv("SIGNALFORGE_REQUIRE_OWNER_AUTH", raising=False) + monkeypatch.delenv("SIGNALFORGE_OWNER_API_TOKEN", raising=False) + + response = TestClient(app).post("/api/settings/platforms/mock/test") + + assert response.status_code == 200 + assert response.json()["status"] == "available" + + +def test_owner_auth_required_rejects_api_without_matching_token(monkeypatch) -> None: + monkeypatch.setenv("SIGNALFORGE_REQUIRE_OWNER_AUTH", "true") + monkeypatch.setenv("SIGNALFORGE_OWNER_API_TOKEN", "owner-secret") + client = TestClient(app) + + missing = client.post("/api/settings/platforms/mock/test") + wrong = client.post("/api/settings/platforms/mock/test", headers={"X-SignalForge-Owner-Token": "wrong"}) + allowed = client.post("/api/settings/platforms/mock/test", headers={"X-SignalForge-Owner-Token": "owner-secret"}) + + assert missing.status_code == 401 + assert wrong.status_code == 401 + assert allowed.status_code == 200 + assert "owner-secret" not in missing.text + assert "wrong" not in wrong.text + + +def test_health_remains_public_when_owner_auth_required(monkeypatch) -> None: + monkeypatch.setenv("SIGNALFORGE_REQUIRE_OWNER_AUTH", "true") + monkeypatch.setenv("SIGNALFORGE_OWNER_API_TOKEN", "owner-secret") + + response = TestClient(app).get("/health") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" diff --git a/apps/api/tests/test_processing_classifier.py b/apps/api/tests/test_processing_classifier.py index 6413106..6b594bf 100644 --- a/apps/api/tests/test_processing_classifier.py +++ b/apps/api/tests/test_processing_classifier.py @@ -6,8 +6,10 @@ classify_llm_payload_or_fallback, classify_text, fallback_classify, + real_llm_classify, validate_classification_payload, ) +from app.processing.llm_client import LLMProviderError from app.processing.types import ALLOWED_SIGNAL_TYPES, clamp_score @@ -111,3 +113,59 @@ def test_out_of_range_llm_json_falls_back_and_counts_failure() -> None: assert result.counters.fallback_classification_count == 1 assert result.signal_type == "workflow_pain" assert 0 <= result.pain_level <= 100 + + +def test_real_llm_classifier_accepts_provider_json_from_fake_client() -> None: + class FakeLLMClient: + def classify_signal(self, text: str) -> str: + assert "wallet" in text + return """{ + "is_need_signal": true, + "signal_type": "security_concern", + "pain_level": 86, + "clarity_score": 80, + "urgency_score": 78, + "business_relevance": 82, + "model_confidence": 76, + "signal_confidence": 84, + "summary_zh": "用户担心钱包连接安全。", + "recommended_action": "Review wallet permissions and trust copy." + }""" + + result = real_llm_classify( + "wallet connection feels unsafe", + env={ + "SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING": "true", + "LLM_BASE_URL": "https://llm.example.test/v1", + "LLM_API_KEY": "secret", + "LLM_MODEL": "test-model", + }, + client=FakeLLMClient(), + ) + + assert result.signal_type == "security_concern" + assert result.metadata["classification_source"] == "real_llm" + assert result.counters.llm_json_failure_count == 0 + assert result.counters.fallback_classification_count == 0 + + +def test_real_llm_classifier_falls_back_on_provider_error_without_leaking_secret() -> None: + class FailingLLMClient: + def classify_signal(self, text: str) -> str: + raise LLMProviderError("Authorization Bearer secret-token failed") + + result = real_llm_classify( + "Looking for an alternative to expensive analytics", + env={ + "SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING": "true", + "LLM_BASE_URL": "https://llm.example.test/v1", + "LLM_API_KEY": "secret-token", + "LLM_MODEL": "test-model", + }, + client=FailingLLMClient(), + ) + + assert result.signal_type == "alternative_search" + assert result.counters.llm_json_failure_count == 1 + assert result.counters.fallback_classification_count == 1 + assert "secret-token" not in str(result.model_dump()) diff --git a/apps/api/tests/test_processing_embedding.py b/apps/api/tests/test_processing_embedding.py index 83adbab..e1bfb7f 100644 --- a/apps/api/tests/test_processing_embedding.py +++ b/apps/api/tests/test_processing_embedding.py @@ -2,9 +2,13 @@ import math +import pytest + from app.processing.embedding_client import ( EMBEDDING_DIMENSION, + EmbeddingProviderError, MockEmbeddingClient, + OpenAICompatibleEmbeddingClient, deterministic_mock_embedding, validate_embedding, ) @@ -34,3 +38,69 @@ def test_mock_embedding_client_returns_model_name_and_valid_vector() -> None: assert result.model_name == "signalforge-mock-embedding-v1" assert result.text == "Discord alpha groups are too noisy." validate_embedding(result.embedding) + + +def test_openai_compatible_embedding_client_posts_to_embeddings_endpoint(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + return None + + def read(self, _limit: int) -> bytes: + return b'{"data":[{"embedding":[' + b",".join([b"0.0"] * EMBEDDING_DIMENSION) + b"]}]}" + + def fake_urlopen(request, *, timeout): + captured["url"] = request.full_url + captured["timeout"] = timeout + captured["authorization"] = request.headers["Authorization"] + captured["body"] = request.data.decode("utf-8") + return FakeResponse() + + monkeypatch.setattr("app.processing.embedding_client.urllib.request.urlopen", fake_urlopen) + + result = OpenAICompatibleEmbeddingClient( + base_url="https://llm.example.test/v1", + api_key="secret-value", + model="text-embedding-3-small", + timeout_seconds=3.0, + ).embed_text("Need better alerts") + + assert captured["url"] == "https://llm.example.test/v1/embeddings" + assert captured["authorization"] == "Bearer secret-value" + assert '"model": "text-embedding-3-small"' in captured["body"] + assert result.model_name == "text-embedding-3-small" + assert len(result.embedding) == EMBEDDING_DIMENSION + + +def test_openai_compatible_embedding_client_rejects_invalid_dimensions(monkeypatch) -> None: + class FakeResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + return None + + def read(self, _limit: int) -> bytes: + return b'{"data":[{"embedding":[0.1,0.2]}]}' + + monkeypatch.setattr( + "app.processing.embedding_client.urllib.request.urlopen", + lambda request, *, timeout: FakeResponse(), + ) + + client = OpenAICompatibleEmbeddingClient( + base_url="https://llm.example.test", + api_key="secret-value", + model="text-embedding-3-small", + ) + + with pytest.raises(EmbeddingProviderError, match="dimension 1536"): + client.embed_text("Need better alerts") diff --git a/apps/api/tests/test_processing_pipeline.py b/apps/api/tests/test_processing_pipeline.py index 08f980c..181b6df 100644 --- a/apps/api/tests/test_processing_pipeline.py +++ b/apps/api/tests/test_processing_pipeline.py @@ -8,6 +8,9 @@ from app.db.models import ClusterSignal, Embedding, Opportunity, Project, RawItem, Signal from app.db.session import SessionLocal from app.main import app +from app.processing.classifier import validate_classification_payload +from app.processing.embedding_client import EMBEDDING_DIMENSION, EmbeddingProviderError, EmbeddingResult +from app.services import processing_pipeline def client() -> TestClient: @@ -159,10 +162,149 @@ def test_processing_summary_preserves_source_url_and_excludes_deleted_high_value delete_project(project_id) +def test_processing_pipeline_real_llm_classification_uses_mock_embeddings(monkeypatch) -> None: + project_id = create_raw_only_project() + calls: list[str] = [] + + def fake_classify_text(text: str, *, mode: str = "mock", llm_payload=None): + calls.append(mode) + return validate_classification_payload( + { + "is_need_signal": True, + "signal_type": "workflow_pain", + "pain_level": 81, + "clarity_score": 76, + "urgency_score": 74, + "business_relevance": 79, + "model_confidence": 72, + "signal_confidence": 78, + "summary_zh": "真实大模型分类路径生成的测试摘要。", + "recommended_action": "Validate the LLM-classified workflow pain.", + }, + metadata={"classification_source": "real_llm"}, + ) + + monkeypatch.setattr("app.processing.pipeline.classify_text", fake_classify_text) + try: + assert SessionLocal is not None + with SessionLocal() as db: + payload = processing_pipeline.process_project( + db=db, + project_id=project_id, + mode="real_llm_classification", + reprocess=False, + ) + + assert payload["mode"] == "real_llm_classification" + assert payload["processed_in_run"] == 6 + assert payload["embedding_count"] == 5 + assert payload["opportunity_count"] >= 1 + assert "real_llm_classification" in calls + finally: + delete_project(project_id) + + +def test_processing_pipeline_real_embedding_uses_fallback_classification_and_real_embedding_client(monkeypatch) -> None: + project_id = create_raw_only_project() + classify_modes: list[str] = [] + embedded_texts: list[str] = [] + + def fake_classify_text(text: str, *, mode: str = "mock", llm_payload=None): + classify_modes.append(mode) + return validate_classification_payload( + { + "is_need_signal": True, + "signal_type": "workflow_pain", + "pain_level": 78, + "clarity_score": 75, + "urgency_score": 70, + "business_relevance": 80, + "model_confidence": 71, + "signal_confidence": 77, + "summary_zh": "fallback 分类路径生成的测试摘要。", + "recommended_action": "Validate the real embedding backend path.", + }, + metadata={"classification_source": mode}, + ) + + class FakeRealEmbeddingClient: + model_name = "text-embedding-test" + + @classmethod + def from_env(cls): + return cls() + + def embed_text(self, text: str) -> EmbeddingResult: + embedded_texts.append(text) + return EmbeddingResult(text=text, embedding=[0.0] * EMBEDDING_DIMENSION, model_name=self.model_name) + + monkeypatch.setattr("app.processing.pipeline.classify_text", fake_classify_text) + monkeypatch.setattr("app.processing.pipeline.OpenAICompatibleEmbeddingClient", FakeRealEmbeddingClient) + try: + assert SessionLocal is not None + with SessionLocal() as db: + payload = processing_pipeline.process_project( + db=db, + project_id=project_id, + mode="real_embedding", + reprocess=False, + ) + + assert payload["mode"] == "real_embedding" + assert payload["embedding_count"] == 5 + assert classify_modes + assert set(classify_modes) == {"fallback_only"} + assert len(embedded_texts) == 5 + with SessionLocal() as db: + model_names = set( + db.scalars( + select(Embedding.model_name) + .join(Signal, Signal.id == Embedding.signal_id) + .where(Signal.project_id == UUID(project_id)) + ) + ) + assert model_names == {"text-embedding-test"} + finally: + delete_project(project_id) + + +def test_processing_pipeline_real_embedding_provider_error_rolls_back_invalid_vector(monkeypatch) -> None: + project_id = create_raw_only_project() + + class InvalidRealEmbeddingClient: + @classmethod + def from_env(cls): + return cls() + + def embed_text(self, text: str) -> EmbeddingResult: + return EmbeddingResult(text=text, embedding=[0.1, 0.2], model_name="bad-provider") + + monkeypatch.setattr("app.processing.pipeline.OpenAICompatibleEmbeddingClient", InvalidRealEmbeddingClient) + try: + assert SessionLocal is not None + with SessionLocal() as db: + try: + processing_pipeline.process_project( + db=db, + project_id=project_id, + mode="real_embedding", + reprocess=False, + ) + except EmbeddingProviderError as exc: + assert "dimension 1536" in str(exc) + else: + raise AssertionError("expected EmbeddingProviderError") + + assert counts(project_id)["signals"] == 0 + assert counts(project_id)["embeddings"] == 0 + finally: + delete_project(project_id) + + def test_processing_rejects_real_provider_modes() -> None: project_id = create_raw_only_project() try: - for mode in ("real_llm", "real_embedding"): + for mode in ("real_llm", "real_llm_classification", "real_embedding"): response = client().post(f"/api/projects/{project_id}/process", json={"mode": mode, "reprocess": False}) payload = response.json() assert response.status_code == 409 diff --git a/apps/api/tests/test_product_hunt_connector.py b/apps/api/tests/test_product_hunt_connector.py index 4dbd438..d933833 100644 --- a/apps/api/tests/test_product_hunt_connector.py +++ b/apps/api/tests/test_product_hunt_connector.py @@ -26,7 +26,7 @@ def test_product_hunt_missing_token_returns_disabled() -> None: } -def test_product_hunt_posts_products_and_comments_are_normalized() -> None: +def test_product_hunt_topic_query_posts_and_comments_are_normalized() -> None: requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: @@ -38,47 +38,54 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.headers["authorization"] == f"Bearer {SECRET}" body = json.loads(request.content) assert "query" in body - assert body["variables"]["query"] == "wallet onboarding" + assert "search:" not in body["query"] + assert "products" not in body["query"] + assert "topics(query: $query, first: $topicsFirst)" in body["query"] + assert "posts(first: $first)" in body["query"] + assert body["variables"] == { + "query": "wallet" if len(requests) == 1 else "onboarding", + "topicsFirst": 3, + "first": 10, + "commentsFirst": 5, + } return httpx.Response( 200, headers={"Content-Type": "application/json"}, json={ "data": { - "posts": { + "topics": { "edges": [ { "node": { - "id": "post-1", - "slug": "launch-wallet", - "name": "Launch Wallet", - "tagline": "Wallet onboarding feedback", - "description": "Users want a faster onboarding flow.", - "url": "https://www.producthunt.com/posts/launch-wallet", - "votesCount": 42, - "commentsCount": 1, - "createdAt": "2026-04-01T10:00:00Z", - "user": {"id": "user-1", "username": "maker"}, - "products": { + "id": "topic-1", + "slug": "wallet", + "name": "Wallet", + "posts": { "edges": [ { "node": { - "id": "product-1", + "id": "post-1", "slug": "launch-wallet", "name": "Launch Wallet", - "tagline": "Wallet analytics", - "url": "https://www.producthunt.com/products/launch-wallet", - } - } - ] - }, - "comments": { - "edges": [ - { - "node": { - "id": "comment-1", - "body": "The onboarding checklist needs clearer wallet setup steps.", - "createdAt": "2026-04-01T11:00:00Z", - "user": {"id": "user-2"}, + "tagline": "Wallet onboarding feedback", + "description": "Users want a faster onboarding flow.", + "url": "https://www.producthunt.com/posts/launch-wallet", + "votesCount": 42, + "commentsCount": 1, + "createdAt": "2026-04-01T10:00:00Z", + "user": {"id": "user-1", "username": "maker"}, + "comments": { + "edges": [ + { + "node": { + "id": "comment-1", + "body": "The onboarding checklist needs clearer wallet setup steps.", + "createdAt": "2026-04-01T11:00:00Z", + "user": {"id": "user-2"}, + } + } + ] + }, } } ] @@ -98,17 +105,150 @@ def handler(request: httpx.Request) -> httpx.Response: ) result = connector.collect(_config(keywords=["wallet", "onboarding"])) - assert len(requests) == 1 + assert len(requests) == 2 assert result.status == ConnectorStatus.SUCCESS - assert result.items_collected == 3 + assert result.items_collected == 2 assert {item.platform_item_id for item in result.items} == { "post:post-1", - "product:product-1", "comment:comment-1", } assert all(item.source_url for item in result.items) + post = next(item for item in result.items if item.platform_item_id == "post:post-1") + assert post.keyword_hits == ["wallet", "onboarding"] comment = next(item for item in result.items if item.platform_item_id == "comment:comment-1") assert comment.source_url == "https://www.producthunt.com/posts/launch-wallet#comment-comment-1" + assert comment.keyword_hits == ["wallet", "onboarding"] + + +def test_product_hunt_topic_query_limits_to_three_keywords() -> None: + queries: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + queries.append(body["variables"]["query"]) + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + json={"data": {"topics": {"edges": []}}}, + request=request, + ) + + connector = ProductHuntConnector( + env={"PRODUCT_HUNT_TOKEN": SECRET}, + http_client=_client(handler), + ) + result = connector.collect( + _config(keywords=["wallet", "onboarding", "ai", "billing", "wallet"]) + ) + + assert result.status == ConnectorStatus.SUCCESS + assert result.items_collected == 0 + assert queries == ["wallet", "onboarding", "ai"] + + +def test_product_hunt_topic_query_no_topic_matches_returns_success_without_broad_fallback() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + body = json.loads(request.content) + assert "topics(query: $query, first: $topicsFirst)" in body["query"] + assert "posts(first: $first)" in body["query"] + assert body["variables"]["query"] == "wallet" + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + json={"data": {"topics": {"edges": []}}}, + request=request, + ) + + connector = ProductHuntConnector( + env={"PRODUCT_HUNT_TOKEN": SECRET}, + http_client=_client(handler), + ) + result = connector.collect(_config(keywords=["wallet"])) + + assert len(requests) == 1 + assert result.status == ConnectorStatus.SUCCESS + assert result.items_collected == 0 + assert result.items == [] + + +def test_product_hunt_topic_query_applies_exclude_keywords_locally() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + json={ + "data": { + "topics": { + "edges": [ + { + "node": { + "id": "topic-1", + "name": "Wallet", + "posts": { + "edges": [ + { + "node": { + "id": "post-1", + "name": "Wallet Jobs", + "tagline": "Hiring for onboarding", + "url": "https://www.producthunt.com/posts/wallet-jobs", + } + } + ] + }, + } + } + ] + } + } + }, + request=request, + ) + + connector = ProductHuntConnector( + env={"PRODUCT_HUNT_TOKEN": SECRET}, + http_client=_client(handler), + ) + result = connector.collect(_config(keywords=["wallet"], exclude_keywords=["hiring"])) + + assert result.status == ConnectorStatus.SUCCESS + assert result.items_collected == 0 + assert result.items_skipped == 1 + + +def test_product_hunt_broad_posts_query_only_when_no_keywords() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + body = json.loads(request.content) + assert "topics(" not in body["query"] + assert "posts(first: $first)" in body["query"] + assert "search:" not in body["query"] + assert "products" not in body["query"] + assert body["variables"] == { + "first": 10, + "commentsFirst": 5, + } + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + json={"data": {"posts": {"edges": []}}}, + request=request, + ) + + connector = ProductHuntConnector( + env={"PRODUCT_HUNT_TOKEN": SECRET}, + http_client=_client(handler), + ) + result = connector.collect(_config(keywords=[])) + + assert len(requests) == 1 + assert result.status == ConnectorStatus.SUCCESS + assert result.items_collected == 0 def test_product_hunt_missing_rate_headers_success_does_not_fail() -> None: @@ -124,13 +264,52 @@ def handler(request: httpx.Request) -> httpx.Response: env={"PRODUCT_HUNT_TOKEN": SECRET}, http_client=_client(handler), ) - result = connector.collect(_config()) + result = connector.collect(_config(keywords=[])) assert result.status == ConnectorStatus.SUCCESS assert result.rate_limit_state is None assert result.items_collected == 0 +def test_product_hunt_large_success_response_is_not_truncated_before_json_parse() -> None: + large_description = "wallet onboarding " * 5000 + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + json={ + "data": { + "posts": { + "edges": [ + { + "node": { + "id": "post-large", + "slug": "large-wallet", + "name": "Large Wallet", + "tagline": "Wallet onboarding", + "description": large_description, + "url": "https://www.producthunt.com/posts/large-wallet", + } + } + ] + } + } + }, + request=request, + ) + + connector = ProductHuntConnector( + env={"PRODUCT_HUNT_TOKEN": SECRET}, + transport=httpx.MockTransport(handler), + ) + result = connector.collect(_config(keywords=[])) + + assert result.status == ConnectorStatus.SUCCESS + assert result.items_collected == 1 + assert result.items[0].platform_item_id == "post:post-large" + + def test_product_hunt_graphql_permission_error_maps_to_permission_limited() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( @@ -230,10 +409,12 @@ def _client(handler: httpx.MockTransport | httpx.SyncByteStream | object) -> Con def _config( *, keywords: list[str] | None = None, + exclude_keywords: list[str] | None = None, ) -> ProjectCollectionConfig: return ProjectCollectionConfig( project_id=uuid4(), platform="product_hunt", - keywords=keywords or ["wallet", "onboarding"], + keywords=["wallet", "onboarding"] if keywords is None else keywords, + exclude_keywords=exclude_keywords or [], max_items=10, ) diff --git a/apps/api/tests/test_production_lifecycle_runs_api.py b/apps/api/tests/test_production_lifecycle_runs_api.py new file mode 100644 index 0000000..9cbe647 --- /dev/null +++ b/apps/api/tests/test_production_lifecycle_runs_api.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +from uuid import UUID, uuid4 + +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.db.models import CollectionJob, CollectionLog, ProductionLifecycleRun, Project +from app.db.session import SessionLocal +from app.main import app +from app.services import production_runs as production_run_service + + +def create_project() -> str: + assert SessionLocal is not None + with SessionLocal() as db: + project = Project( + name=f"Production Run API Test {uuid4()}", + description="Production lifecycle API integration test", + platforms_enabled={"mock": True}, + collection_frequency="manual", + ) + db.add(project) + db.commit() + db.refresh(project) + return str(project.id) + + +def delete_project(project_id: str) -> None: + assert SessionLocal is not None + with SessionLocal() as db: + project = db.get(Project, UUID(project_id)) + if project is not None: + db.delete(project) + db.commit() + + +def delete_run(run_id: str) -> None: + assert SessionLocal is not None + with SessionLocal() as db: + run = db.get(ProductionLifecycleRun, UUID(run_id)) + if run is not None: + db.delete(run) + db.commit() + + +def test_production_lifecycle_run_mock_success(monkeypatch) -> None: + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", raising=False) + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_LLM_SMOKE", raising=False) + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", raising=False) + project_id = create_project() + run_id = None + try: + response = TestClient(app).post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "mock", + "processing_mode": "fallback_only", + }, + ) + payload = response.json() + run_id = payload["id"] + + assert response.status_code == 201 + assert payload["project_id"] == project_id + assert payload["status"] == "success" + assert payload["stage"] == "closeout" + assert payload["collection_mode"] == "mock" + assert payload["processing_mode"] == "fallback_only" + assert payload["allow_real_platform_write"] is False + assert payload["allow_real_llm"] is False + assert payload["allow_real_embedding"] is False + assert payload["env_preflight"]["safe_execution"] is True + assert payload["result_summary"]["collection"]["status"] == "success" + assert [event["stage"] for event in payload["result_summary"]["lifecycle"]] == [ + "preflight", + "collect", + "process", + "review", + "report", + "closeout", + ] + + fetched = TestClient(app).get(f"/api/production/runs/{run_id}") + assert fetched.status_code == 200 + assert fetched.json()["id"] == run_id + + closed = TestClient(app).post( + f"/api/production/runs/{run_id}/closeout", + json={"rollback_hint": "Delete this run record if needed."}, + ) + assert closed.status_code == 200 + assert closed.json()["status"] == "closed" + assert closed.json()["stage"] == "closeout" + assert closed.json()["result_summary"]["lifecycle"][-1]["stage"] == "closeout" + finally: + if run_id is not None: + delete_run(run_id) + delete_project(project_id) + + +def test_production_lifecycle_run_honors_reprocess_flag(monkeypatch) -> None: + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", raising=False) + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_LLM_SMOKE", raising=False) + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", raising=False) + project_id = create_project() + run_ids: list[str] = [] + try: + first = TestClient(app).post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "mock", + "processing_mode": "fallback_only", + }, + ) + assert first.status_code == 201 + run_ids.append(first.json()["id"]) + + second = TestClient(app).post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "mock", + "processing_mode": "fallback_only", + "reprocess": True, + }, + ) + payload = second.json() + run_ids.append(payload["id"]) + + assert second.status_code == 201 + assert payload["stage"] == "closeout" + assert payload["result_summary"]["processing"]["processed_in_run"] >= 1 + finally: + for run_id in run_ids: + delete_run(run_id) + delete_project(project_id) + + +def test_production_lifecycle_run_stops_when_collection_fails(monkeypatch) -> None: + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", raising=False) + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_LLM_SMOKE", raising=False) + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", raising=False) + project_id = create_project() + run_id = None + + def fake_execute_collection(db, *, project_id, execution_mode, **_kwargs): + job = CollectionJob( + project_id=project_id, + status="failed", + trigger_type="manual", + error_summary="product_hunt: invalid JSON", + ) + db.add(job) + db.flush() + db.add( + CollectionLog( + job_id=job.id, + platform=execution_mode, + status="failed", + items_collected=0, + items_inserted=0, + items_skipped=0, + error_message="Product Hunt returned invalid JSON", + ) + ) + db.commit() + db.refresh(job) + return job + + monkeypatch.setattr(production_run_service, "execute_collection", fake_execute_collection) + try: + response = TestClient(app).post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "mock", + "processing_mode": "fallback_only", + }, + ) + payload = response.json() + run_id = payload["id"] + + assert response.status_code == 201 + assert payload["status"] == "failed" + assert payload["stage"] == "collect" + assert payload["error_summary"] == "product_hunt: invalid JSON" + assert payload["result_summary"]["collection"]["status"] == "failed" + assert [event["stage"] for event in payload["result_summary"]["lifecycle"]] == [ + "preflight", + "collect", + ] + assert "processing" not in payload["result_summary"] + finally: + if run_id is not None: + delete_run(run_id) + delete_project(project_id) + + +def test_production_lifecycle_run_real_provider_no_go_without_approvals_or_env(monkeypatch) -> None: + for name in ( + "SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE", + "SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", + "SIGNALFORGE_ALLOW_REAL_LLM_SMOKE", + "SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", + "REDDIT_CLIENT_ID", + "REDDIT_CLIENT_SECRET", + "REDDIT_USER_AGENT", + "PRODUCT_HUNT_TOKEN", + "OPENAI_API_KEY", + ): + monkeypatch.delenv(name, raising=False) + project_id = create_project() + run_id = None + try: + response = TestClient(app).post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "p0_real", + "processing_mode": "real_embedding", + "allow_real_platform_write": False, + "allow_real_llm": False, + "allow_real_embedding": False, + "redacted_logs": [{"message": "token=super-secret-token"}], + }, + ) + payload = response.json() + run_id = payload["id"] + + assert response.status_code == 201 + assert payload["status"] == "no_go_real_provider" + assert payload["stage"] == "preflight" + assert payload["result_summary"]["lifecycle"][0]["stage"] == "preflight" + assert payload["error_summary"] == "Real provider mode blocked by missing approval or environment flags." + assert "missing" in payload["env_preflight"] + assert "super-secret-token" not in response.text + assert "REDDIT_CLIENT_SECRET" in response.text + finally: + if run_id is not None: + delete_run(run_id) + delete_project(project_id) + + +def test_real_llm_classification_preflight_does_not_require_embedding_gate(monkeypatch) -> None: + for name in ( + "SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING", + "SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", + "LLM_BASE_URL", + "LLM_API_KEY", + "LLM_MODEL", + ): + monkeypatch.delenv(name, raising=False) + + project_id = create_project() + run_ids: list[str] = [] + client = TestClient(app) + + def create_real_llm_run() -> dict: + response = client.post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "mock", + "processing_mode": "real_llm_classification", + "allow_real_llm": True, + "allow_real_embedding": False, + "execute": False, + }, + ) + payload = response.json() + assert response.status_code == 201 + run_ids.append(payload["id"]) + assert "llm-secret-value" not in response.text + return payload + + try: + missing_gate = create_real_llm_run() + assert missing_gate["status"] == "no_go_real_provider" + assert "SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING" in missing_gate["env_preflight"]["missing"] + assert "SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE" not in missing_gate["env_preflight"]["missing"] + assert "allow_real_embedding" not in missing_gate["env_preflight"]["blocked"] + + monkeypatch.setenv("SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING", "true") + monkeypatch.setenv("LLM_BASE_URL", "https://llm.example.test/v1") + monkeypatch.setenv("LLM_API_KEY", "llm-secret-value") + monkeypatch.setenv("LLM_MODEL", "test-model") + passed = create_real_llm_run() + assert passed["status"] == "preflight_passed" + assert passed["env_preflight"]["missing"] == [] + assert passed["env_preflight"]["blocked"] == [] + finally: + for run_id in run_ids: + delete_run(run_id) + delete_project(project_id) + + +def test_real_embedding_preflight_requires_embedding_gate_approval_and_env_only(monkeypatch) -> None: + for name in ( + "SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING", + "SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", + "LLM_BASE_URL", + "LLM_API_KEY", + "LLM_MODEL", + "EMBEDDING_MODEL", + ): + monkeypatch.delenv(name, raising=False) + + project_id = create_project() + run_ids: list[str] = [] + client = TestClient(app) + + def create_real_embedding_run(*, allow_real_embedding: bool) -> dict: + response = client.post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "mock", + "processing_mode": "real_embedding", + "allow_real_llm": False, + "allow_real_embedding": allow_real_embedding, + "execute": False, + }, + ) + payload = response.json() + assert response.status_code == 201 + run_ids.append(payload["id"]) + assert "embedding-secret-value" not in response.text + return payload + + try: + blocked = create_real_embedding_run(allow_real_embedding=False) + assert blocked["status"] == "no_go_real_provider" + assert "allow_real_embedding" in blocked["env_preflight"]["blocked"] + assert "allow_real_llm" not in blocked["env_preflight"]["blocked"] + assert "SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE" in blocked["env_preflight"]["missing"] + assert "SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING" not in blocked["env_preflight"]["missing"] + assert "LLM_MODEL" not in blocked["env_preflight"]["missing"] + + monkeypatch.setenv("SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", "true") + monkeypatch.setenv("LLM_BASE_URL", "https://llm.example.test/v1") + monkeypatch.setenv("LLM_API_KEY", "embedding-secret-value") + monkeypatch.setenv("EMBEDDING_MODEL", "text-embedding-test") + passed = create_real_embedding_run(allow_real_embedding=True) + assert passed["status"] == "preflight_passed" + assert passed["env_preflight"]["missing"] == [] + assert passed["env_preflight"]["blocked"] == [] + finally: + for run_id in run_ids: + delete_run(run_id) + delete_project(project_id) + + +def test_real_embedding_production_run_executes_only_after_gate_and_run_approval(monkeypatch) -> None: + project_id = create_project() + run_id = None + called_modes: list[str] = [] + + def fake_execute_collection(db, *, project_id, execution_mode, **_kwargs): + job = CollectionJob( + project_id=project_id, + status="success", + trigger_type="manual", + ) + db.add(job) + db.flush() + db.add( + CollectionLog( + job_id=job.id, + platform=execution_mode, + status="success", + items_collected=0, + items_inserted=0, + items_skipped=0, + ) + ) + db.commit() + db.refresh(job) + return job + + def fake_process_project(db, *, project_id, mode, reprocess, **_kwargs): + called_modes.append(mode) + return { + "mode": mode, + "reprocess": reprocess, + "processed_in_run": 0, + "embedding_count": 0, + "cluster_count": 0, + } + + monkeypatch.setenv("SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE", "true") + monkeypatch.setenv("LLM_BASE_URL", "https://llm.example.test/v1") + monkeypatch.setenv("LLM_API_KEY", "embedding-secret-value") + monkeypatch.setenv("EMBEDDING_MODEL", "text-embedding-test") + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING", raising=False) + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.setattr(production_run_service, "execute_collection", fake_execute_collection) + monkeypatch.setattr(production_run_service.processing_pipeline, "process_project", fake_process_project) + try: + response = TestClient(app).post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "mock", + "processing_mode": "real_embedding", + "allow_real_embedding": True, + }, + ) + payload = response.json() + run_id = payload["id"] + + assert response.status_code == 201 + assert payload["status"] == "success" + assert payload["result_summary"]["processing"]["mode"] == "real_embedding" + assert called_modes == ["real_embedding"] + finally: + if run_id is not None: + delete_run(run_id) + delete_project(project_id) + + +def test_product_hunt_production_run_requires_smoke_write_and_run_approval(monkeypatch) -> None: + project_id = create_project() + run_ids: list[str] = [] + secret = "ph-secret-value" + client = TestClient(app) + + def create_product_hunt_run(*, allow_write: bool) -> dict: + response = client.post( + "/api/production/runs", + json={ + "project_id": project_id, + "collection_mode": "product_hunt", + "processing_mode": "fallback_only", + "allow_real_platform_write": allow_write, + }, + ) + payload = response.json() + assert response.status_code == 201 + run_ids.append(payload["id"]) + assert secret not in response.text + return payload + + try: + monkeypatch.setenv("PRODUCT_HUNT_TOKEN", secret) + monkeypatch.setenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", "true") + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE", raising=False) + missing_smoke = create_product_hunt_run(allow_write=True) + assert missing_smoke["status"] == "no_go_real_provider" + assert "SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE" in missing_smoke["env_preflight"]["missing"] + + monkeypatch.setenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE", "true") + monkeypatch.delenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", raising=False) + missing_write_env = create_product_hunt_run(allow_write=True) + assert missing_write_env["status"] == "no_go_real_provider" + assert "SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE" in missing_write_env["env_preflight"]["missing"] + + monkeypatch.setenv("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE", "true") + missing_run_approval = create_product_hunt_run(allow_write=False) + assert missing_run_approval["status"] == "no_go_real_provider" + assert "allow_real_platform_write" in missing_run_approval["env_preflight"]["blocked"] + finally: + for run_id in run_ids: + delete_run(run_id) + delete_project(project_id) + + +def test_production_lifecycle_runs_list_recent() -> None: + project_id = create_project() + run_id = None + try: + created = TestClient(app).post( + "/api/production/runs", + json={"project_id": project_id, "collection_mode": "mock", "processing_mode": "mock"}, + ) + assert created.status_code == 201 + run_id = created.json()["id"] + + listed = TestClient(app).get("/api/production/runs?page_size=5") + + assert listed.status_code == 200 + assert any(item["id"] == run_id for item in listed.json()["items"]) + finally: + if run_id is not None: + delete_run(run_id) + delete_project(project_id) diff --git a/apps/api/tests/test_reports_api.py b/apps/api/tests/test_reports_api.py index e38bbf1..eabda97 100644 --- a/apps/api/tests/test_reports_api.py +++ b/apps/api/tests/test_reports_api.py @@ -23,6 +23,7 @@ def test_markdown_report_preserves_source_url_and_omits_secrets() -> None: assert response.status_code == 200 assert payload["format"] == "markdown" assert "source_url: https://example.com/" in payload["content"] + assert "recommended_action:" in payload["content"] assert "encrypted_payload" not in payload["content"] assert "token" not in payload["content"].lower() @@ -34,6 +35,7 @@ def test_csv_report_contains_required_signal_fields_and_source_url() -> None: assert response.status_code == 200 assert payload["format"] == "csv" assert payload["content_type"] == "text/csv" - assert "signal_id,platform,signal_type,pain_level,summary_zh,source_url,created_at" in payload["content"] + assert "signal_id,platform,signal_type,pain_level,summary_zh,recommended_action,source_url,mode,created_at" in payload["content"] assert "https://example.com/" in payload["content"] + assert "mock/fallback_only" in payload["content"] assert "encrypted_payload" not in payload["content"] diff --git a/apps/api/tests/test_settings_api.py b/apps/api/tests/test_settings_api.py index f1a1c9a..338dede 100644 --- a/apps/api/tests/test_settings_api.py +++ b/apps/api/tests/test_settings_api.py @@ -2,6 +2,7 @@ from fastapi.testclient import TestClient +from app.connectors.credential_resolver import PRODUCT_HUNT_ENV_VARS, REDDIT_ENV_VARS from app.main import app @@ -18,6 +19,37 @@ def test_settings_platforms_include_all_project_phases() -> None: assert by_platform["product_hunt"]["phase"] == "P0" assert by_platform["x"]["phase"] == "P1" assert by_platform["discord"]["phase"] == "P2" + assert by_platform["x"]["status"] == "disabled" + assert by_platform["discord"]["status"] == "disabled" + + +def test_settings_platforms_report_env_backed_p0_configuration(monkeypatch) -> None: + client = TestClient(app) + for name in REDDIT_ENV_VARS: + monkeypatch.setenv(name, f"{name.lower()}-value") + for name in PRODUCT_HUNT_ENV_VARS: + monkeypatch.setenv(name, f"{name.lower()}-value") + + response = client.get("/api/settings/platforms") + + assert response.status_code == 200 + by_platform = {item["platform"]: item for item in response.json()["platforms"]} + assert by_platform["reddit"]["status"] == "configured" + assert by_platform["product_hunt"]["status"] == "configured" + assert str(response.json()).count("value") == 0 + + +def test_credential_status_reports_env_backed_p0_configuration(monkeypatch) -> None: + client = TestClient(app) + for name in REDDIT_ENV_VARS: + monkeypatch.setenv(name, f"{name.lower()}-value") + + response = client.get("/api/settings/credentials/status") + + assert response.status_code == 200 + by_platform = {item["platform"]: item for item in response.json()["credentials"]} + assert by_platform["reddit"]["status"] == "configured" + assert by_platform["x"]["status"] == "disabled" def test_credential_status_does_not_return_secret_payload() -> None: @@ -31,3 +63,68 @@ def test_credential_status_does_not_return_secret_payload() -> None: assert "token" not in str(body).lower() credentials = body["credentials"] assert {item["platform"] for item in credentials} == {"reddit", "product_hunt", "x", "discord"} + + +def test_platform_env_test_reports_missing_required_env(monkeypatch) -> None: + client = TestClient(app) + for name in REDDIT_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + response = client.post("/api/settings/platforms/reddit/test") + + assert response.status_code == 200 + body = response.json() + assert body["platform"] == "reddit" + assert body["status"] == "missing_env" + assert body["required_env_missing"] == list(REDDIT_ENV_VARS) + assert body["checked_at"] + + +def test_platform_env_test_reports_mock_as_available() -> None: + client = TestClient(app) + + response = client.post("/api/settings/platforms/mock/test") + + assert response.status_code == 200 + body = response.json() + assert body["platform"] == "mock" + assert body["status"] == "available" + assert body["required_env_missing"] == [] + + +def test_platform_env_test_reports_configured_unverified_without_secret_values(monkeypatch) -> None: + client = TestClient(app) + secret = "ph-test-token" + for name in PRODUCT_HUNT_ENV_VARS: + monkeypatch.setenv(name, secret) + + response = client.post("/api/settings/platforms/product_hunt/test") + + assert response.status_code == 200 + body = response.json() + assert body["platform"] == "product_hunt" + assert body["status"] == "configured_unverified" + assert body["required_env_missing"] == [] + assert secret not in str(body) + assert "Bearer" not in str(body) + + +def test_platform_env_test_reports_future_platforms_as_coming_soon() -> None: + client = TestClient(app) + + response = client.post("/api/settings/platforms/x/test") + + assert response.status_code == 200 + body = response.json() + assert body["platform"] == "x" + assert body["status"] == "coming_soon" + assert body["required_env_missing"] == [] + + +def test_platform_env_test_rejects_unsupported_platform() -> None: + client = TestClient(app) + + response = client.post("/api/settings/platforms/unknown/test") + + assert response.status_code == 404 + assert response.json()["error"]["code"] == "not_found" diff --git a/apps/web/app/api/[...path]/route.ts b/apps/web/app/api/[...path]/route.ts new file mode 100644 index 0000000..39beb31 --- /dev/null +++ b/apps/web/app/api/[...path]/route.ts @@ -0,0 +1,219 @@ +import { SERVER_API_BASE_URL } from "../../../lib/constants"; +import { + getOwnerApiToken, + isOwnerAuthRequired, + isValidOwnerSessionValue, + OWNER_SESSION_COOKIE +} from "../../../lib/ownerAuth"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type RouteContext = { + params: Promise<{ path?: string[] }> | { path?: string[] }; +}; + +const BLOCKED_REQUEST_HEADERS = new Set([ + "authorization", + "connection", + "cookie", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "sf-token", + "sf_token", + "x-sf-token", + "x-signalforge-owner-token", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +const BLOCKED_RESPONSE_HEADERS = new Set(["connection", "content-encoding", "set-cookie"]); + +class OwnerAuthProxyError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "OwnerAuthProxyError"; + this.status = status; + } +} + +export async function GET(request: Request, context: RouteContext) { + return proxyBackendRequest(request, context); +} + +export async function POST(request: Request, context: RouteContext) { + return proxyBackendRequest(request, context); +} + +export async function PUT(request: Request, context: RouteContext) { + return proxyBackendRequest(request, context); +} + +export async function PATCH(request: Request, context: RouteContext) { + return proxyBackendRequest(request, context); +} + +export async function DELETE(request: Request, context: RouteContext) { + return proxyBackendRequest(request, context); +} + +export async function OPTIONS(request: Request, context: RouteContext) { + return proxyBackendRequest(request, context); +} + +export async function HEAD(request: Request, context: RouteContext) { + return proxyBackendRequest(request, context); +} + +async function proxyBackendRequest(request: Request, context: RouteContext): Promise { + try { + const ownerToken = getProxyOwnerToken(request.headers); + const targetUrl = await buildTargetUrl(request, context); + const body = await getRequestBody(request); + + const upstreamResponse = await fetch(targetUrl, { + body, + cache: "no-store", + headers: getForwardHeaders(request.headers, ownerToken), + method: request.method, + redirect: "manual" + }); + + return new Response(upstreamResponse.body, { + headers: getResponseHeaders(upstreamResponse.headers), + status: upstreamResponse.status, + statusText: upstreamResponse.statusText + }); + } catch (error) { + if (error instanceof OwnerAuthProxyError) { + return Response.json( + { + error: { + code: "owner_auth_required", + message: error.message, + details: {} + } + }, + { + status: error.status, + headers: { + "Cache-Control": "no-store" + } + } + ); + } + + return Response.json( + { + error: { + code: "backend_proxy_unavailable", + message: "SignalForge backend is unavailable through the web proxy.", + details: {} + } + }, + { + status: 502, + headers: { + "Cache-Control": "no-store" + } + } + ); + } +} + +function getProxyOwnerToken(headers: Headers): string | null { + if (!isOwnerAuthRequired()) { + return null; + } + + const sessionCookie = parseCookieHeader(headers.get("cookie")).get(OWNER_SESSION_COOKIE); + if (!isValidOwnerSessionValue(sessionCookie)) { + throw new OwnerAuthProxyError("Owner session is required for the SignalForge web proxy.", 401); + } + + return getOwnerApiToken(); +} + +async function buildTargetUrl(request: Request, context: RouteContext): Promise { + const incomingUrl = new URL(request.url); + const params = await Promise.resolve(context.params); + const path = (params.path ?? []).join("/"); + const upstreamPath = path === "health" ? "/health" : `/api/${path}`; + const targetUrl = new URL(upstreamPath, `${SERVER_API_BASE_URL}/`); + + incomingUrl.searchParams.forEach((value, key) => { + if (key !== "sf_token") { + targetUrl.searchParams.append(key, value); + } + }); + + return targetUrl; +} + +async function getRequestBody(request: Request): Promise { + if (request.method === "GET" || request.method === "HEAD") { + return undefined; + } + + const body = await request.arrayBuffer(); + return body.byteLength > 0 ? body : undefined; +} + +function getForwardHeaders(headers: Headers, ownerToken: string | null): Headers { + const forwardHeaders = new Headers(); + + headers.forEach((value, key) => { + if (!BLOCKED_REQUEST_HEADERS.has(key.toLowerCase())) { + forwardHeaders.set(key, value); + } + }); + + if (ownerToken) { + forwardHeaders.set("X-SignalForge-Owner-Token", ownerToken); + } + + return forwardHeaders; +} + +function getResponseHeaders(headers: Headers): Headers { + const responseHeaders = new Headers(); + + headers.forEach((value, key) => { + if (!BLOCKED_RESPONSE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value); + } + }); + + return responseHeaders; +} + +function parseCookieHeader(cookieHeader: string | null): Map { + const cookies = new Map(); + + if (!cookieHeader) { + return cookies; + } + + cookieHeader.split(";").forEach((part) => { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName) { + cookies.set(rawName, safeDecodeCookieValue(rawValue.join("="))); + } + }); + + return cookies; +} + +function safeDecodeCookieValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1ce6c12..d208e5f 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,7 +5,7 @@ import "./styles.css"; export const metadata: Metadata = { title: "SignalForge", - description: "SignalForge research workstation" + description: "SignalForge 研究工作台" }; export default function RootLayout({ @@ -14,7 +14,7 @@ export default function RootLayout({ children: ReactNode; }>) { return ( - + {children} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..0916581 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,103 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { + getOwnerSessionToken, + isOwnerAuthRequired, + OWNER_SESSION_COOKIE, + ownerAuthConfigError, + verifyOwnerPassword +} from "../../lib/ownerAuth"; + +type LoginPageProps = { + searchParams?: Promise<{ error?: string; next?: string }> | { error?: string; next?: string }; +}; + +export default async function LoginPage({ searchParams }: LoginPageProps) { + const resolvedSearchParams = await Promise.resolve(searchParams ?? {}); + const configError = ownerAuthConfigError(); + const nextPath = sanitizeNextPath(resolvedSearchParams.next); + + if (!isOwnerAuthRequired()) { + redirect(nextPath); + } + + return ( +
+
+
+

管理员访问

+

+ SignalForge 本地生产登录 +

+

+ 请输入 Mac mini 单人生产环境密码。会话使用 HttpOnly cookie 保存,不在前端代码中保存密钥。 +

+
+ {configError ? ( +
+

认证配置缺失

+

{configError}

+
+ ) : null} + {resolvedSearchParams.error === "invalid" ? ( +

+ 密码不正确,未创建管理员会话。 +

+ ) : null} +
+ + + +
+
+
+ ); +} + +async function loginOwner(formData: FormData) { + "use server"; + + const password = String(formData.get("password") ?? ""); + const nextPath = sanitizeNextPath(String(formData.get("next") ?? "")); + const sessionToken = getOwnerSessionToken(); + + if (!sessionToken || !verifyOwnerPassword(password)) { + redirect(`/login?error=invalid&next=${encodeURIComponent(nextPath)}`); + } + + const cookieStore = await cookies(); + cookieStore.set({ + name: OWNER_SESSION_COOKIE, + value: sessionToken, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 12 + }); + + redirect(nextPath); +} + +function sanitizeNextPath(value: string | undefined | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/signals"; + } + + if (value.startsWith("/login")) { + return "/signals"; + } + + return value; +} diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx new file mode 100644 index 0000000..54b1c6c --- /dev/null +++ b/apps/web/app/onboarding/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { FormEvent, useState } from "react"; +import { api, ApiClientError } from "../../lib/api"; +import { ROUTES } from "../../lib/constants"; +import type { KeywordType } from "../../lib/types"; +import { Button } from "../../components/ui/Button"; + +type SubmitState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string }; + +export default function OnboardingPage() { + const router = useRouter(); + const [projectName, setProjectName] = useState("个人生产 E2E"); + const [includeKeywords, setIncludeKeywords] = useState("钱包引导, 价格清晰度"); + const [excludeKeywords, setExcludeKeywords] = useState("抽奖"); + const [mockEnabled, setMockEnabled] = useState(true); + const [state, setState] = useState({ status: "idle" }); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setState({ status: "loading" }); + + try { + const project = await api.projects.create({ + name: projectName.trim(), + description: "Created by Personal Production v1 onboarding.", + platforms_enabled: { mock: mockEnabled }, + collection_frequency: "manual" + }); + + const includeItems = parseKeywords(includeKeywords); + const excludeItems = parseKeywords(excludeKeywords); + await Promise.all([ + ...includeItems.map((keyword, index) => + createKeyword(project.id, keyword, index === 0 ? "main" : "related") + ), + ...excludeItems.map((keyword) => createKeyword(project.id, keyword, "exclude")) + ]); + + router.push(`${ROUTES.dashboard}?${new URLSearchParams({ projectId: project.id })}`); + } catch (error) { + setState({ status: "error", message: formatActionError(error) }); + } + } + + const isSubmitting = state.status === "loading"; + + return ( +
+
+
+

引导

+

创建个人信号项目

+

+ 创建项目、关键词和模拟平台配置,然后进入业务闭环控制台。 +

+
+
+ +
void handleSubmit(event)}> +
+ + + + +
+ +
+ + +
+ + {state.status === "error" ? ( +

+ {state.message} +

+ ) : null} +
+
+ ); +} + +function parseKeywords(value: string): string[] { + return value + .split(",") + .map((keyword) => keyword.trim()) + .filter(Boolean); +} + +function createKeyword(projectId: string, keyword: string, keywordType: KeywordType) { + return api.keywords.create(projectId, { + keyword, + keyword_type: keywordType, + language: "all", + enabled: true + }); +} + +function formatActionError(error: unknown): string { + if (error instanceof ApiClientError) { + return `${error.code}${error.status ? ` (${error.status})` : ""}: ${error.message}`; + } + + return "未知前端错误。"; +} diff --git a/apps/web/app/opportunities/[id]/page.tsx b/apps/web/app/opportunities/[id]/page.tsx index 8e0accd..296e833 100644 --- a/apps/web/app/opportunities/[id]/page.tsx +++ b/apps/web/app/opportunities/[id]/page.tsx @@ -26,7 +26,7 @@ export default async function OpportunityDetailPage({ return ; } catch (error) { - return ; + return ; } } diff --git a/apps/web/app/opportunities/page.tsx b/apps/web/app/opportunities/page.tsx index 4728e3f..3a918eb 100644 --- a/apps/web/app/opportunities/page.tsx +++ b/apps/web/app/opportunities/page.tsx @@ -1,88 +1,10 @@ -import type { CSSProperties } from "react"; -import { OpportunityBoard } from "../../components/opportunities/OpportunityBoard"; -import { EmptyState } from "../../components/ui/EmptyState"; -import { ErrorState } from "../../components/ui/ErrorState"; -import { api } from "../../lib/api"; - -type SearchParams = Record; - -type OpportunitiesPageProps = { - searchParams?: Promise; -}; - -const pageStyle: CSSProperties = { - display: "grid", - gap: 16 -}; - -const headerStyle: CSSProperties = { - display: "flex", - alignItems: "flex-start", - justifyContent: "space-between", - gap: 16, - flexWrap: "wrap" -}; - -const titleStyle: CSSProperties = { - margin: 0, - color: "var(--text)", - fontSize: 24, - fontWeight: 800, - lineHeight: 1.2 -}; - -const subtitleStyle: CSSProperties = { - margin: "6px 0 0", - color: "var(--muted)", - lineHeight: 1.5 -}; - -export default async function OpportunitiesPage({ searchParams }: OpportunitiesPageProps) { - const resolvedSearchParams: SearchParams = searchParams ? await searchParams : {}; - const projectId = getSingleSearchParam(resolvedSearchParams.projectId); - - if (!projectId) { - return ( - - ); - } - - try { - const response = await api.opportunities.list(projectId, { page_size: 100 }); - - return ( -
-
-
-

Opportunity Board

-

- Grouped by status for project {projectId}. Showing {response.items.length} of{" "} - {response.total} opportunities. -

-
-
- {response.items.length === 0 ? ( - - ) : ( - - )} -
- ); - } catch (error) { - return ; - } -} - -function getSingleSearchParam(value: string | string[] | undefined): string | null { - if (Array.isArray(value)) { - return value[0] ?? null; - } - - return value ?? null; +import { Suspense } from "react"; +import { OpportunitiesPage } from "../../components/opportunities/OpportunitiesPage"; + +export default function Page() { + return ( + + + + ); } diff --git a/apps/web/app/production/page.tsx b/apps/web/app/production/page.tsx new file mode 100644 index 0000000..f0168f0 --- /dev/null +++ b/apps/web/app/production/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { ProductionPage } from "../../components/production/ProductionPage"; + +export default function Page() { + return ( + + + + ); +} diff --git a/apps/web/app/styles.css b/apps/web/app/styles.css index a640ff5..ed840f3 100644 --- a/apps/web/app/styles.css +++ b/apps/web/app/styles.css @@ -1,20 +1,26 @@ :root { color-scheme: light; - --background: #f6f8fb; + --background: #f8fafc; --surface: #ffffff; - --surface-subtle: #f9fafc; - --surface-strong: #eef3f8; - --text: #172033; - --muted: #647084; - --muted-strong: #465267; - --border: #d9e0ea; - --border-strong: #bdc8d8; - --accent: #245f73; - --accent-strong: #174a5e; - --positive: #16794f; - --warning: #a15c10; - --danger: #b42318; - --shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + --surface-subtle: #f5f3f5; + --surface-strong: #eae7e9; + --surface-raised: #fbf9fa; + --text: #0f172a; + --muted: #94a3b8; + --muted-strong: #475569; + --border: #e2e8f0; + --border-strong: #c5c6cd; + --accent: #1d2b3e; + --accent-strong: #334155; + --accent-soft: #d5e3fd; + --high-value: #ea580c; + --high-value-bg: #fff7ed; + --positive: #16a34a; + --warning: #d97706; + --danger: #dc2626; + --disabled: #cbd5e1; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-raised: 0 10px 28px -24px rgba(15, 23, 42, 0.55); } * { @@ -43,62 +49,65 @@ a { button, input, -select { +select, +textarea { font: inherit; } .appShell { min-height: 100vh; display: grid; - grid-template-columns: 248px minmax(0, 1fr); - grid-template-rows: 56px minmax(0, 1fr); + grid-template-columns: 256px minmax(0, 1fr); + grid-template-rows: 48px minmax(0, 1fr); background: var(--background); } .topBar { - grid-column: 1 / -1; + grid-column: 2; + grid-row: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; - height: 56px; - padding: 0 20px; + height: 48px; + padding: 0 24px; border-bottom: 1px solid var(--border); - background: var(--surface); - box-shadow: var(--shadow); + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(12px); } .brandLockup { min-width: 0; - display: flex; - align-items: baseline; - gap: 10px; + display: grid; + gap: 1px; } .brandName { margin: 0; color: var(--text); - font-size: 16px; + font-size: 14px; font-weight: 750; letter-spacing: 0; } .brandPhase { - color: var(--accent); - font-size: 12px; + color: var(--muted-strong); + font-size: 11px; font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; } .sidebar { grid-column: 1; - grid-row: 2; + grid-row: 1 / -1; display: flex; min-height: 0; flex-direction: column; - gap: 16px; - padding: 16px; + gap: 18px; + padding: 0 16px 16px; border-right: 1px solid var(--border); - background: var(--surface-subtle); + background: var(--surface); } .workspaceArea { @@ -111,30 +120,82 @@ select { .contentFrame { width: 100%; - min-height: calc(100vh - 56px); - padding: 20px; + min-height: calc(100vh - 48px); + padding: 24px; +} + +.brandPanel { + display: flex; + align-items: center; + gap: 12px; + min-height: 64px; + border-bottom: 1px solid var(--border); +} + +.brandMark { + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border-radius: 4px; + background: var(--accent); + color: #ffffff; + font-size: 13px; + font-weight: 800; + letter-spacing: 0; +} + +.brandTitle { + margin: 0; + color: var(--text); + font-size: 18px; + font-weight: 800; + line-height: 1.1; +} + +.brandSubtitle { + margin: 2px 0 0; + color: var(--muted-strong); + font-size: 12px; + line-height: 1.2; +} + +.sidebarAction { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + background: var(--accent); + color: #ffffff; + font-weight: 760; + cursor: default; } .sectionLabel { margin: 0 0 8px; - color: var(--muted); + color: var(--muted-strong); font-size: 11px; font-weight: 750; - letter-spacing: 0.04em; + letter-spacing: 0.05em; text-transform: uppercase; } .projectSelector { display: grid; gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 16px; } .selectControl { width: 100%; min-height: 36px; - border: 1px solid var(--border-strong); - border-radius: 6px; - background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + background: #ffffff; color: var(--text); padding: 7px 10px; } @@ -153,29 +214,31 @@ select { .navList { display: grid; - gap: 4px; + gap: 3px; } .navItem { display: flex; - min-height: 34px; + min-height: 36px; align-items: center; justify-content: space-between; gap: 10px; - border-radius: 6px; - padding: 7px 10px; + border-radius: 4px; + border-right: 2px solid transparent; + padding: 8px 10px; color: var(--muted-strong); - font-weight: 650; + font-weight: 680; } .navItem:hover { - background: var(--surface-strong); + background: #f8fafc; color: var(--text); } .navItemActive { - background: #e6f0f3; - color: var(--accent-strong); + border-right-color: var(--accent-strong); + background: #f1f5f9; + color: var(--text); } .navHint { @@ -188,7 +251,7 @@ select { display: flex; align-items: center; gap: 8px; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; } @@ -206,10 +269,15 @@ select { justify-content: center; gap: 8px; border: 1px solid transparent; - border-radius: 6px; + border-radius: 4px; padding: 6px 12px; cursor: pointer; - font-weight: 700; + font-weight: 760; + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease, + opacity 140ms ease; } .button:disabled { @@ -222,17 +290,31 @@ select { color: #ffffff; } +.buttonPrimary:hover:not(:disabled) { + background: var(--accent-strong); +} + .buttonSecondary { - border-color: var(--border-strong); + border-color: var(--border); background: var(--surface); color: var(--text); } +.buttonSecondary:hover:not(:disabled) { + background: #f8fafc; + border-color: var(--border-strong); +} + .buttonGhost { background: transparent; color: var(--muted-strong); } +.buttonGhost:hover:not(:disabled) { + background: #f8fafc; + color: var(--text); +} + .buttonSmall { min-height: 28px; padding: 4px 9px; @@ -258,24 +340,24 @@ select { } .badgeNeutral { - background: var(--surface-strong); + background: #f1f5f9; } .badgeSuccess { - border-color: #b6ddc8; - background: #e7f5ed; + border-color: #bbf7d0; + background: #f0fdf4; color: var(--positive); } .badgeWarning { - border-color: #f3d4a7; - background: #fff3df; + border-color: #fed7aa; + background: var(--high-value-bg); color: var(--warning); } .badgeDanger { - border-color: #f2b8b5; - background: #ffebe9; + border-color: #fecaca; + background: #fef2f2; color: var(--danger); } @@ -284,14 +366,14 @@ select { gap: 4px; min-width: 0; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 6px; background: var(--surface); padding: 12px; } .metricLabel { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; font-weight: 700; } @@ -299,14 +381,14 @@ select { .metricValue { margin: 0; color: var(--text); - font-size: 22px; + font-size: 24px; font-weight: 780; line-height: 1.15; } .metricDetail { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; line-height: 1.35; } @@ -316,7 +398,7 @@ select { gap: 8px; width: 100%; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 6px; background: var(--surface); padding: 16px; } @@ -335,7 +417,7 @@ select { .stateText { margin: 0; - color: var(--muted); + color: var(--muted-strong); line-height: 1.5; } @@ -348,7 +430,7 @@ select { display: flex; align-items: center; gap: 10px; - color: var(--muted); + color: var(--muted-strong); } .spinner { @@ -360,6 +442,549 @@ select { animation: spin 0.8s linear infinite; } +.pageHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.pageEyebrow { + margin: 0 0 4px; + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.pageTitle { + margin: 0; + color: var(--text); + font-size: 20px; + font-weight: 780; + line-height: 1.35; +} + +.pageSubtitle { + margin: 4px 0 0; + max-width: 780px; + color: var(--muted-strong); + line-height: 1.5; +} + +.surfacePanel { + display: grid; + gap: 12px; + min-width: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + padding: 16px; +} + +.onboardingGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.dataTableWrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} + +.dataTable { + width: 100%; + min-width: 760px; + border-collapse: collapse; +} + +.dataTable th, +.dataTable td { + border-bottom: 1px solid var(--border); + padding: 10px 12px; + text-align: left; + vertical-align: top; +} + +.dataTable th { + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.dataTable td { + color: var(--text); +} + +.dataTable tbody tr:hover { + background: #f8fafc; +} + +.opportunityBoard { + display: grid; + grid-template-columns: repeat(6, minmax(220px, 1fr)); + gap: 12px; + align-items: start; + overflow-x: auto; + padding-bottom: 4px; +} + +.opportunityColumn { + display: grid; + gap: 10px; + min-width: 220px; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + padding: 10px; +} + +.opportunityColumnHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.opportunityColumnTitle { + margin: 0; + color: var(--text); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.opportunityCount { + min-width: 24px; + border-radius: 999px; + background: #e2e8f0; + color: var(--muted-strong); + padding: 2px 8px; + text-align: center; + font-size: 12px; + font-weight: 800; +} + +.opportunityCardList { + display: grid; + gap: 10px; +} + +.opportunityCard { + display: grid; + gap: 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + padding: 12px; + box-shadow: var(--shadow); +} + +.opportunityCardHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.opportunityCardTitle { + margin: 0; + color: var(--text); + font-size: 14px; + font-weight: 780; + line-height: 1.35; +} + +.opportunityMetaGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.opportunityCardActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.opportunityCardActions .selectControl { + width: auto; + min-width: 132px; + min-height: 28px; + padding: 4px 8px; + font-size: 12px; +} + +.compactMeta { + display: grid; + gap: 2px; + min-width: 0; +} + +.compactMetaLabel { + margin: 0; + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.compactMetaValue { + margin: 0; + color: var(--text); + overflow-wrap: anywhere; +} + +.detailPage { + display: grid; + gap: 18px; + max-width: 1120px; + margin: 0 auto; +} + +.detailHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.detailTitleBlock { + display: grid; + gap: 8px; + min-width: 0; +} + +.detailTitle { + margin: 0; + color: var(--text); + font-size: 22px; + font-weight: 800; + line-height: 1.22; +} + +.detailDescription { + margin: 0; + max-width: 820px; + color: var(--muted-strong); + line-height: 1.55; +} + +.detailActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.compactField { + display: grid; + gap: 5px; + min-width: 132px; + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.compactField input, +.compactField select { + text-transform: none; + letter-spacing: 0; +} + +.inlineError, +.inlineSuccess { + margin: 0; + font-size: 12px; + line-height: 1.45; +} + +.inlineError { + color: var(--danger); +} + +.inlineSuccess { + color: var(--positive); +} + +.metricGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.evidenceGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.reportsPreview { + min-height: 360px; + width: 100%; + resize: vertical; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + background: #f8fafc; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + line-height: 1.55; + padding: 12px; +} + +.reportOptionsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 10px; +} + +.csvPreview { + max-height: 260px; + overflow: auto; + margin: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + color: var(--text); + font-size: 12px; + line-height: 1.5; + padding: 12px; + white-space: pre; +} + +.integrationList { + display: grid; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--surface); +} + +.integrationRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--border); + padding: 16px; +} + +.integrationRow:last-child { + border-bottom: 0; +} + +.integrationRow:hover { + background: #f8fafc; +} + +.integrationIdentity { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} + +.integrationIcon { + display: inline-flex; + width: 40px; + height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + color: var(--accent); + font-weight: 800; +} + +.integrationName { + margin: 0; + color: var(--text); + font-weight: 780; +} + +.envStatusGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.envStatusItem { + display: grid; + gap: 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + padding: 12px; +} + +.envStatusTopline { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.integrationMeta { + margin: 4px 0 0; + color: var(--muted-strong); + font-size: 12px; +} + +.integrationBadges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.formGrid { + gap: 16px; +} + +.formField { + display: grid; + gap: 6px; +} + +.formField span { + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.formField input, +.formField textarea, +.formField select { + width: 100%; + border: 1px solid var(--border); + border-radius: 4px; + background: #ffffff; + color: var(--text); + padding: 9px 10px; +} + +.formField small, +.inlineError, +.inlineSuccess { + font-size: 12px; + line-height: 1.45; +} + +.formField small { + color: var(--muted); +} + +.loginPage { + min-height: 100vh; + display: grid; + align-items: center; + justify-items: center; + padding: 24px; + background: var(--background); +} + +.loginPanel { + display: grid; + gap: 18px; + width: min(100%, 440px); + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + padding: 24px; + box-shadow: var(--shadow-raised); +} + +.productionGrid { + display: grid; + grid-template-columns: minmax(360px, 1.35fr) minmax(300px, 0.85fr); + gap: 12px; + align-items: start; +} + +.productionControlGrid { + display: grid; + grid-template-columns: repeat(3, minmax(132px, 1fr)); + gap: 10px; + align-items: end; +} + +.checkboxRow { + display: flex; + align-items: flex-start; + gap: 8px; + color: var(--text); + line-height: 1.4; +} + +.checkboxRow input { + width: 16px; + height: 16px; + margin: 2px 0 0; + flex: 0 0 auto; +} + +.productionCheckbox { + min-height: 36px; + align-items: center; + color: var(--muted-strong); + font-size: 12px; + font-weight: 700; +} + +.approvalList { + display: grid; + gap: 8px; +} + +.approvalItem { + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + padding: 10px; +} + +.approvalItem span { + display: grid; + gap: 2px; +} + +.approvalItem strong { + font-size: 13px; +} + +.approvalItem small { + color: var(--muted-strong); +} + +.inlineError { + margin: 0; + color: var(--danger); +} + +.inlineSuccess { + margin: 0; + color: var(--positive); +} + @keyframes spin { to { transform: rotate(360deg); @@ -373,6 +998,8 @@ select { } .topBar { + grid-column: 1; + grid-row: 1; height: auto; min-height: 56px; align-items: flex-start; @@ -400,4 +1027,9 @@ select { .navList { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .productionGrid, + .productionControlGrid { + grid-template-columns: 1fr; + } } diff --git a/apps/web/components/dashboard/Dashboard.module.css b/apps/web/components/dashboard/Dashboard.module.css index f25d353..6e575b6 100644 --- a/apps/web/components/dashboard/Dashboard.module.css +++ b/apps/web/components/dashboard/Dashboard.module.css @@ -1,6 +1,8 @@ .page { display: grid; - gap: 16px; + gap: 24px; + max-width: 1280px; + margin: 0 auto; } .header { @@ -18,17 +20,17 @@ .eyebrow { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 11px; - font-weight: 750; - letter-spacing: 0.04em; + font-weight: 800; + letter-spacing: 0.05em; text-transform: uppercase; } .title { margin: 0; color: var(--text); - font-size: 24px; + font-size: 20px; font-weight: 780; letter-spacing: 0; line-height: 1.2; @@ -36,14 +38,14 @@ .subtitle { margin: 0; - color: var(--muted); + color: var(--muted-strong); line-height: 1.45; } .grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); - gap: 16px; + gap: 18px; } .panel { @@ -51,9 +53,73 @@ gap: 12px; min-width: 0; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 6px; + background: var(--surface); + padding: 18px; + box-shadow: var(--shadow); +} + +.controlsPanel { + display: grid; + gap: 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + padding: 16px; + box-shadow: var(--shadow); +} + +.controlsGrid { + display: grid; + grid-template-columns: minmax(160px, 220px) auto minmax(160px, 220px) auto auto; + gap: 10px; + align-items: end; +} + +.controlField { + display: grid; + gap: 6px; + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.controlField select { + min-height: 34px; + border: 1px solid var(--border); + border-radius: 4px; background: var(--surface); - padding: 14px; + color: var(--text); + padding: 7px 10px; + text-transform: none; + letter-spacing: 0; +} + +.inlineCheck { + display: inline-flex; + min-height: 34px; + align-items: center; + gap: 7px; + color: var(--muted-strong); + font-size: 12px; + font-weight: 700; +} + +.inlineError, +.inlineSuccess { + margin: 0; + font-size: 12px; + line-height: 1.45; +} + +.inlineError { + color: var(--danger); +} + +.inlineSuccess { + color: var(--positive); } .span3 { @@ -87,13 +153,13 @@ margin: 0; color: var(--text); font-size: 14px; - font-weight: 760; + font-weight: 780; line-height: 1.3; } .panelMeta { margin: 2px 0 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; line-height: 1.4; } @@ -101,7 +167,7 @@ .largeValue { margin: 0; color: var(--text); - font-size: 34px; + font-size: 38px; font-weight: 800; line-height: 1; } @@ -114,16 +180,16 @@ .qualityBar { width: 100%; - height: 8px; + height: 9px; overflow: hidden; border-radius: 999px; - background: var(--surface-strong); + background: #f1f5f9; } .qualityFill { height: 100%; border-radius: inherit; - background: var(--positive); + background: var(--high-value); } .list { @@ -142,7 +208,7 @@ border: 1px solid var(--border); border-radius: 6px; padding: 10px; - background: var(--surface-subtle); + background: #f8fafc; } .itemTitle { @@ -163,7 +229,7 @@ } .score { - color: var(--accent-strong); + color: var(--high-value); font-weight: 780; text-align: right; white-space: nowrap; @@ -181,7 +247,7 @@ border: 1px solid var(--border); border-radius: 6px; padding: 10px; - background: var(--surface-subtle); + background: #f8fafc; } .platformName { @@ -229,7 +295,7 @@ .logMeta { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; line-height: 1.4; } @@ -241,7 +307,7 @@ .emptyText { margin: 0; - color: var(--muted); + color: var(--muted-strong); line-height: 1.5; } @@ -274,7 +340,8 @@ .metricRow, .platformGrid, - .statusLayout { + .statusLayout, + .controlsGrid { grid-template-columns: 1fr; } } diff --git a/apps/web/components/dashboard/Dashboard.tsx b/apps/web/components/dashboard/Dashboard.tsx index 347ee37..fb40515 100644 --- a/apps/web/components/dashboard/Dashboard.tsx +++ b/apps/web/components/dashboard/Dashboard.tsx @@ -5,6 +5,7 @@ import { formatNumber, formatPercent, formatScore, + formatStatusLabel, platformLabel } from "../../lib/format"; import type { @@ -17,6 +18,7 @@ import type { import { Badge } from "../ui/Badge"; import { EmptyState } from "../ui/EmptyState"; import { Metric } from "../ui/Metric"; +import { DashboardControls } from "./DashboardControls"; import styles from "./Dashboard.module.css"; type DashboardProps = { @@ -42,8 +44,8 @@ export async function Dashboard({ projectId }: DashboardProps) {
); @@ -54,6 +56,7 @@ export async function Dashboard({ projectId }: DashboardProps) { return (
+
@@ -69,19 +72,18 @@ function PageHeader({ errors = [] }: { errors?: string[] }) { return (
-

Dashboard

-

Signal Quality Summary

+

仪表盘

+

信号质量摘要

- Signal Quality Gate summary with high value ratio, top opportunities, platform readiness, - and recent collection / processing status. + 汇总高价值占比、重点机会、平台状态以及最近采集 / 处理状态。

{errors.length > 0 ? ( - Partial data + 部分数据 ) : ( - Live API + API 在线 )}
); @@ -92,9 +94,9 @@ function TodaySignalsPanel({ count }: { count: number }) {
Signals} + value={信号} />

{formatNumber(count)}

@@ -110,30 +112,30 @@ function HighValuePanel({ summary }: { summary: ProcessingSummary | null }) {
0 ? "success" : "neutral"}>{formatPercent(highValueRatio)}} />

{formatNumber(highValueCount)}

-
+
@@ -146,12 +148,12 @@ function TopOpportunitiesPanel({ opportunities }: { opportunities: Opportunity[]
{formatNumber(opportunities.length)} shown} + meta="按机会评分排序" + title="重点机会" + value={显示 {formatNumber(opportunities.length)} 个} /> {opportunities.length === 0 ? ( -

No opportunities returned for this project.

+

当前项目暂无机会。

) : (
    {opportunities.map((opportunity) => ( @@ -159,9 +161,9 @@ function TopOpportunitiesPanel({ opportunities }: { opportunities: Opportunity[]

    {opportunity.title}

    - {formatNumber(opportunity.evidence_count)} evidence + {formatNumber(opportunity.evidence_count)} 条证据 - {formatStatus(opportunity.status)} + {formatStatusLabel(opportunity.status)}
    @@ -181,9 +183,9 @@ function PlatformStatusPanel({ platforms }: { platforms: PlatformStatus[] }) {
    4 platforms} + value={4 个平台} />
    {PLATFORM_ORDER.map((platformName) => { @@ -194,9 +196,9 @@ function PlatformStatusPanel({ platforms }: { platforms: PlatformStatus[] }) {

    {platformLabel(platformName)}

    - {formatStatus(status)} + {formatStatusLabel(status)} - {platform?.enabled_for_mvp ? "MVP enabled" : "MVP off"} + {platform?.enabled_for_mvp ? "MVP 已启用" : "MVP 未启用"} {platform ? {platform.phase} : null}
    @@ -219,28 +221,28 @@ function RecentStatusPanel({
    {summary ? "Summary ready" : "No summary"}} + meta="采集日志和处理摘要" + title="最近运行状态" + value={{summary ? "摘要就绪" : "暂无摘要"}} />
    {logs.length === 0 ? ( -

    No recent collection logs returned for this project.

    +

    当前项目暂无最近采集日志。

    ) : (
      {logs.map((log) => (
    • {platformLabel(log.platform)}

      - {formatStatus(log.status)} + {formatStatusLabel(log.status)} {formatDateTime(log.created_at)}

      - {formatNumber(log.items_collected)} collected / {formatNumber(log.items_inserted)} inserted /{" "} - {formatNumber(log.items_skipped)} skipped + 采集 {formatNumber(log.items_collected)} / 入库 {formatNumber(log.items_inserted)} /{" "} + 跳过 {formatNumber(log.items_skipped)}

      - {log.error_message ?

      Error: {log.error_message}

      : null} + {log.error_message ?

      错误:{log.error_message}

      : null}
    • ))}
    @@ -248,18 +250,18 @@ function RecentStatusPanel({
    @@ -305,11 +307,11 @@ async function loadDashboardData(projectId: string): Promise { ]); const errors = [ - resultError(todaySignals, "today signals"), - resultError(processingSummary, "processing summary"), - resultError(opportunities, "opportunities"), - resultError(platforms, "platform settings"), - resultError(collectionLogs, "collection logs") + resultError(todaySignals, "今日信号"), + resultError(processingSummary, "处理摘要"), + resultError(opportunities, "机会"), + resultError(platforms, "平台设置"), + resultError(collectionLogs, "采集日志") ].filter((error): error is string => Boolean(error)); return { @@ -342,7 +344,7 @@ function resultError(result: PromiseSettledResult, label: string): string return `${label}: ${result.reason.code}`; } - return `${label}: unavailable`; + return `${label}: 不可用`; } function opportunityStatusTone(status: string): DashboardBadgeTone { @@ -380,10 +382,3 @@ function collectionStatusTone(status: string): DashboardBadgeTone { return "warning"; } - -function formatStatus(status: string): string { - return status - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} diff --git a/apps/web/components/dashboard/DashboardControls.tsx b/apps/web/components/dashboard/DashboardControls.tsx new file mode 100644 index 0000000..a82c886 --- /dev/null +++ b/apps/web/components/dashboard/DashboardControls.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { api, ApiClientError } from "../../lib/api"; +import type { CollectionJobCreateResponse, ProcessingRequest, ProcessingResponse } from "../../lib/types"; +import { Button } from "../ui/Button"; +import styles from "./Dashboard.module.css"; + +type DashboardControlsProps = { + projectId: string; +}; + +type ActionState = + | { status: "idle" } + | { status: "loading"; action: "collect" | "process" } + | { status: "success"; message: string } + | { status: "error"; message: string }; + +const COLLECTION_MODES = ["mock", "reddit", "product_hunt", "p0_real"] as const; +const PROCESS_MODES: Array> = ["mock", "fallback_only"]; +type CollectionMode = (typeof COLLECTION_MODES)[number]; +type ProcessMode = NonNullable; + +const COLLECTION_MODE_LABELS: Record = { + mock: "模拟数据", + reddit: "Reddit(需配置)", + product_hunt: "Product Hunt(需配置)", + p0_real: "P0 真实平台(需配置)" +}; + +const COLLECTION_MODE_HINTS: Record = { + mock: "使用本地模拟数据,采集后再运行处理即可生成信号。", + reddit: "需要 Reddit 环境变量;未配置时会安全跳过,入库为 0,不会产生信号。", + product_hunt: "需要 Product Hunt 环境变量;未配置时会安全跳过,入库为 0,不会产生信号。", + p0_real: "会尝试 P0 真实平台;未配置平台环境变量时会安全跳过,入库为 0。" +}; + +const PROCESS_MODE_LABELS: Record = { + mock: "模拟处理", + fallback_only: "规则兜底处理" +}; + +export function DashboardControls({ projectId }: DashboardControlsProps) { + const router = useRouter(); + const [collectionMode, setCollectionMode] = useState("mock"); + const [processMode, setProcessMode] = useState("mock"); + const [reprocess, setReprocess] = useState(false); + const [state, setState] = useState({ status: "idle" }); + + async function runCollection() { + setState({ status: "loading", action: "collect" }); + + try { + const response = await api.collection.collect(projectId, { execution_mode: collectionMode }); + setState({ status: "success", message: formatCollectionMessage(response) }); + router.refresh(); + } catch (error) { + setState({ status: "error", message: formatActionError(error) }); + } + } + + async function runProcessing() { + setState({ status: "loading", action: "process" }); + + try { + const response = await api.processing.run(projectId, { + mode: processMode, + reprocess + }); + setState({ status: "success", message: formatProcessingMessage(response) }); + router.refresh(); + } catch (error) { + setState({ status: "error", message: formatActionError(error) }); + } + } + + const isBusy = state.status === "loading"; + + return ( +
    +
    +

    + 运行控制 +

    +

    触发安全采集和本地处理后刷新仪表盘摘要。

    +
    + +
    + + + + + + +
    + + {state.status === "success" ?

    {state.message}

    : null} + {state.status === "error" ? ( +

    + {state.message} +

    + ) : null} +
    + ); +} + +function formatCollectionMessage(response: CollectionJobCreateResponse): string { + const log = response.log; + const counters = log + ? `采集 ${log.items_collected} / 入库 ${log.items_inserted} / 跳过 ${log.items_skipped}` + : "暂无日志明细"; + const status = log?.status ?? response.status; + const statusLabel = collectionStatusLabel(status); + const modeLabel = + COLLECTION_MODE_LABELS[response.collector_execution as CollectionMode] ?? + response.collector_execution; + const zeroInsertHint = + log && log.items_inserted === 0 + ? "未入库原始数据,因此不会产生信号;请切换到“模拟数据”采集,或先配置真实平台环境变量。" + : null; + const disabledHint = + log && log.status !== "success" + ? "真实平台当前未完成采集,已按安全策略跳过。" + : null; + const detail = [disabledHint, zeroInsertHint].filter(Boolean).join(" "); + + return `采集任务 ${statusLabel}(${modeLabel}):${counters}${detail ? `。${detail}` : ""}`; +} + +function formatProcessingMessage(response: ProcessingResponse): string { + const modeLabel = PROCESS_MODE_LABELS[response.mode] ?? response.mode; + const zeroSignalHint = + response.total_raw_items === 0 + ? "当前没有原始数据,请先运行“模拟数据”采集或完成真实平台配置。" + : null; + + return `处理完成(${modeLabel}):本次处理 ${response.processed_in_run} 条,信号总数 ${response.total_signals}。${zeroSignalHint ?? ""}`; +} + +function collectionStatusLabel(status: string): string { + const labels: Record = { + success: "成功", + disabled: "已跳过", + missing_env: "缺少配置", + rate_limited: "限流", + permission_limited: "权限受限", + error: "失败" + }; + + return labels[status] ?? status; +} + +function formatActionError(error: unknown): string { + if (error instanceof ApiClientError) { + return `${error.code}${error.status ? ` (${error.status})` : ""}: ${error.message}`; + } + + return "未知前端错误。"; +} diff --git a/apps/web/components/layout/AppShell.tsx b/apps/web/components/layout/AppShell.tsx index 0a2cbb2..bb68be6 100644 --- a/apps/web/components/layout/AppShell.tsx +++ b/apps/web/components/layout/AppShell.tsx @@ -1,14 +1,18 @@ "use client"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; import type { ReactNode } from "react"; import { Suspense, useEffect, useState } from "react"; import { api, ApiClientError } from "../../lib/api"; -import { DEFAULT_PROJECT_NAME } from "../../lib/constants"; +import { DEFAULT_PROJECT_NAME, ROUTES } from "../../lib/constants"; import type { Project } from "../../lib/types"; import { Navigation } from "./Navigation"; import { ProjectSelector } from "./ProjectSelector"; type ProjectSelectorState = { + backendStatus: "connected" | "partial" | "unavailable"; + backendStatusLabel: string; projects: Project[]; selectedProjectId: string | null; selectedProjectName: string | null; @@ -17,6 +21,8 @@ type ProjectSelectorState = { }; const INITIAL_PROJECT_STATE: ProjectSelectorState = { + backendStatus: "unavailable", + backendStatusLabel: "后端不可用", projects: [], selectedProjectId: null, selectedProjectName: null, @@ -25,9 +31,14 @@ const INITIAL_PROJECT_STATE: ProjectSelectorState = { }; export function AppShell({ children }: { children: ReactNode }) { + const pathname = usePathname(); const [projectState, setProjectState] = useState(INITIAL_PROJECT_STATE); useEffect(() => { + if (pathname === "/login") { + return; + } + let active = true; getProjectSelectorState() .then((nextState) => { @@ -39,7 +50,9 @@ export function AppShell({ children }: { children: ReactNode }) { if (active) { setProjectState({ ...INITIAL_PROJECT_STATE, - errorMessage: "Unable to load projects from SignalForge backend." + backendStatus: "unavailable", + backendStatusLabel: "后端不可用", + errorMessage: "无法从 SignalForge 后端加载项目。" }); } }); @@ -47,21 +60,41 @@ export function AppShell({ children }: { children: ReactNode }) { return () => { active = false; }; - }, []); + }, [pathname]); + + if (pathname === "/login") { + return <>{children}; + } return (
    -

    SignalForge

    - Phase 6 Frontend MVP +

    信号洞察雷达

    + 前端 MVP
    -
    -
    -
    + {state.status === "loading" || state.status === "idle" ? ( - + ) : null} {state.status === "error" ? ( - + ) : null} {state.status === "ready" && state.response.items.length === 0 ? ( ) : null} {state.status === "ready" && state.response.items.length > 0 ? ( -
    - +
    +
    - - - - - - - - - + + + + + + + + + {state.response.items.map((log) => ( - - - - - - - - - + + + + + + + + + ))} @@ -141,7 +230,7 @@ function sanitizeErrorMessage(value: string | null): string { .map((line) => line.trim()) .find((line) => line && !/^\s*(file ".*", line \d+|at\s+\S+)/i.test(line)); - return truncateText(firstReadableLine || "Collection failed. See backend logs for details.", 140); + return truncateText(firstReadableLine || "采集失败。详情请查看后端日志。", 140); } function statusTone(status: string): "neutral" | "success" | "warning" | "danger" { @@ -162,35 +251,23 @@ function statusTone(status: string): "neutral" | "success" | "warning" | "danger return "neutral"; } -function Th({ children }: { children: ReactNode }) { - return ; +function formatCollectionMessage(response: CollectionJobCreateResponse): string { + const log = response.log; + const detail = log + ? `采集 ${formatNumber(log.items_collected)} / 入库 ${formatNumber(log.items_inserted)} / 跳过 ${formatNumber(log.items_skipped)}` + : "暂无日志明细"; + + return `采集任务 ${formatStatusLabel(response.status)}(${response.collector_execution}):${detail}`; } -function Td({ children }: { children: ReactNode }) { - return ; +function formatActionError(error: unknown): string { + if (error instanceof ApiClientError) { + return `${error.code}${error.status ? ` (${error.status})` : ""}: ${error.message}`; + } + + return "未知前端错误。"; } -const tableStyle: CSSProperties = { - width: "100%", - minWidth: 980, - borderCollapse: "collapse", - border: "1px solid var(--border)", - background: "var(--surface)" -}; - -const headerCellStyle: CSSProperties = { - padding: "10px 12px", - borderBottom: "1px solid var(--border)", - color: "var(--muted)", - fontSize: 12, - fontWeight: 750, - textAlign: "left", - whiteSpace: "nowrap" -}; - -const bodyCellStyle: CSSProperties = { - padding: "10px 12px", - borderBottom: "1px solid var(--border)", - color: "var(--text)", - verticalAlign: "top" -}; +function Th({ children }: { children: string }) { + return ; +} diff --git a/apps/web/components/opportunities/OpportunitiesPage.tsx b/apps/web/components/opportunities/OpportunitiesPage.tsx new file mode 100644 index 0000000..9d4c772 --- /dev/null +++ b/apps/web/components/opportunities/OpportunitiesPage.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { api } from "../../lib/api"; +import type { Opportunity, PaginatedResponse } from "../../lib/types"; +import { EmptyState } from "../ui/EmptyState"; +import { ErrorState } from "../ui/ErrorState"; +import { LoadingState } from "../ui/LoadingState"; +import { OpportunityBoard } from "./OpportunityBoard"; + +type OpportunitiesState = + | { status: "idle" | "loading" } + | { status: "ready"; response: PaginatedResponse } + | { status: "error"; error: unknown }; + +export function OpportunitiesPage() { + const searchParams = useSearchParams(); + const projectId = searchParams.get("projectId"); + const [state, setState] = useState({ status: "idle" }); + + useEffect(() => { + if (!projectId) { + setState({ status: "idle" }); + return; + } + + let active = true; + setState({ status: "loading" }); + + api.opportunities + .list(projectId, { page_size: 100 }) + .then((response) => { + if (active) { + setState({ status: "ready", response }); + } + }) + .catch((error: unknown) => { + if (active) { + setState({ status: "error", error }); + } + }); + + return () => { + active = false; + }; + }, [projectId]); + + if (!projectId) { + return ( + + ); + } + + if (state.status === "idle" || state.status === "loading") { + return ; + } + + if (state.status === "error") { + return ; + } + + if (state.status !== "ready") { + return null; + } + + const response = state.response; + + return ( +
    +
    +
    +

    机会

    +

    机会看板

    +

    + 按状态分组展示项目 {projectId} 的机会。当前显示 {response.items.length} /{" "} + {response.total} 个机会。 +

    +
    +
    + {response.items.length === 0 ? ( + + ) : ( + + )} +
    + ); +} diff --git a/apps/web/components/opportunities/OpportunityBoard.tsx b/apps/web/components/opportunities/OpportunityBoard.tsx index f4eb81c..125781d 100644 --- a/apps/web/components/opportunities/OpportunityBoard.tsx +++ b/apps/web/components/opportunities/OpportunityBoard.tsx @@ -1,4 +1,3 @@ -import type { CSSProperties } from "react"; import { EmptyState } from "../ui/EmptyState"; import type { Opportunity } from "../../lib/types"; import { OpportunityCard } from "./OpportunityCard"; @@ -13,79 +12,36 @@ type OpportunityBoardProps = { projectId: string; }; -const boardStyle: CSSProperties = { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", - gap: 12, - alignItems: "start" -}; - -const columnStyle: CSSProperties = { - display: "grid", - gap: 10, - minWidth: 0, - border: "1px solid var(--border)", - borderRadius: 8, - background: "var(--surface-subtle)", - padding: 10 -}; - -const columnHeaderStyle: CSSProperties = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 8 -}; - -const columnTitleStyle: CSSProperties = { - margin: 0, - color: "var(--text)", - fontSize: 13, - fontWeight: 760 -}; - -const countStyle: CSSProperties = { - minWidth: 24, - borderRadius: 999, - background: "var(--surface-strong)", - color: "var(--muted-strong)", - padding: "2px 8px", - textAlign: "center", - fontSize: 12, - fontWeight: 750 -}; - -const cardListStyle: CSSProperties = { - display: "grid", - gap: 10 -}; - export function OpportunityBoard({ opportunities, projectId }: OpportunityBoardProps) { const grouped = groupByStatus(opportunities); return ( -
    +
    {OPPORTUNITY_STATUSES.map((status) => { const items = grouped[status]; return ( -
    -
    -

    +
    +
    +

    {opportunityStatusLabel(status)}

    - + {items.length}
    {items.length === 0 ? ( ) : ( -
    +
    {items.map((opportunity) => ( (null); + const [error, setError] = useState(null); + const detailHref = buildAllowedQueryHref( + `/opportunities/${encodeURIComponent(opportunity.id)}`, + searchParams, + { projectId } + ); + const selectedStatus = isOpportunityStatus(opportunity.status) ? opportunity.status : "new"; -const metaItemStyle: CSSProperties = { - display: "grid", - gap: 2, - minWidth: 0 -}; + async function updateStatus(status: OpportunityStatus) { + setPendingAction("status"); + setError(null); -const metaLabelStyle: CSSProperties = { - margin: 0, - color: "var(--muted)", - fontSize: 11, - fontWeight: 700, - textTransform: "uppercase" -}; + try { + await api.opportunities.updateStatus(opportunity.id, status); + router.refresh(); + } catch (updateError) { + setError(formatActionError(updateError)); + } finally { + setPendingAction(null); + } + } -const metaValueStyle: CSSProperties = { - margin: 0, - color: "var(--text)", - overflowWrap: "anywhere" -}; + async function archiveOpportunity() { + setPendingAction("archive"); + setError(null); -export function OpportunityCard({ opportunity, projectId }: OpportunityCardProps) { - const detailHref = `/opportunities/${encodeURIComponent(opportunity.id)}?projectId=${encodeURIComponent( - projectId - )}`; + try { + await api.opportunities.archive(opportunity.id); + router.refresh(); + } catch (archiveError) { + setError(formatActionError(archiveError)); + } finally { + setPendingAction(null); + } + } return ( -
    -
    -

    {opportunity.title}

    +
    +
    +

    {opportunity.title}

    {opportunityStatusLabel(opportunity.status)}
    -
    - - - - +
    + + + +
    - - Open detail - +
    + + + + 查看详情 + +
    + {error ? ( +

    + {error} +

    + ) : null}
    ); } function MetaItem({ label, value }: { label: string; value: string }) { return ( -
    -

    {label}

    -

    {value}

    +
    +

    {label}

    +

    {value}

    ); } + +function formatActionError(error: unknown): string { + if (error instanceof ApiClientError) { + return `${error.code}${error.status ? ` (${error.status})` : ""}: ${error.message}`; + } + + return "未知前端错误。"; +} diff --git a/apps/web/components/opportunities/OpportunityDetail.tsx b/apps/web/components/opportunities/OpportunityDetail.tsx index c12778d..1da44e3 100644 --- a/apps/web/components/opportunities/OpportunityDetail.tsx +++ b/apps/web/components/opportunities/OpportunityDetail.tsx @@ -1,11 +1,11 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import type { CSSProperties } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; -import { api, apiRequest } from "../../lib/api"; +import { api } from "../../lib/api"; import { formatDateTime, formatNumber, formatScore } from "../../lib/format"; +import { buildAllowedQueryHref } from "../../lib/query"; import type { Opportunity, OpportunityStatus } from "../../lib/types"; import { Badge } from "../ui/Badge"; import { Button } from "../ui/Button"; @@ -23,102 +23,9 @@ type OpportunityDetailProps = { projectId: string | null; }; -const pageStyle: CSSProperties = { - display: "grid", - gap: 16 -}; - -const headerStyle: CSSProperties = { - display: "flex", - alignItems: "flex-start", - justifyContent: "space-between", - gap: 16, - flexWrap: "wrap" -}; - -const titleBlockStyle: CSSProperties = { - display: "grid", - gap: 8, - minWidth: 0 -}; - -const titleStyle: CSSProperties = { - margin: 0, - color: "var(--text)", - fontSize: 24, - fontWeight: 800, - lineHeight: 1.2 -}; - -const descriptionStyle: CSSProperties = { - margin: 0, - maxWidth: 820, - color: "var(--muted-strong)", - lineHeight: 1.55 -}; - -const actionsStyle: CSSProperties = { - display: "flex", - alignItems: "center", - gap: 8, - flexWrap: "wrap" -}; - -const metricsStyle: CSSProperties = { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", - gap: 12 -}; - -const sectionStyle: CSSProperties = { - display: "grid", - gap: 10, - border: "1px solid var(--border)", - borderRadius: 8, - background: "var(--surface)", - padding: 14 -}; - -const sectionTitleStyle: CSSProperties = { - margin: 0, - color: "var(--text)", - fontSize: 15, - fontWeight: 760 -}; - -const evidenceGridStyle: CSSProperties = { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", - gap: 10 -}; - -const evidenceItemStyle: CSSProperties = { - display: "grid", - gap: 3, - minWidth: 0 -}; - -const labelStyle: CSSProperties = { - margin: 0, - color: "var(--muted)", - fontSize: 12, - fontWeight: 700 -}; - -const valueStyle: CSSProperties = { - margin: 0, - color: "var(--text)", - overflowWrap: "anywhere" -}; - -const mutedStyle: CSSProperties = { - margin: 0, - color: "var(--muted)", - lineHeight: 1.5 -}; - export function OpportunityDetail({ initialOpportunity, projectId }: OpportunityDetailProps) { const router = useRouter(); + const searchParams = useSearchParams(); const [opportunity, setOpportunity] = useState(initialOpportunity); const [error, setError] = useState(null); const [pendingAction, setPendingAction] = useState(null); @@ -128,13 +35,7 @@ export function OpportunityDetail({ initialOpportunity, projectId }: Opportunity setPendingAction("status"); try { - const updated = await apiRequest( - `/api/opportunities/${encodeURIComponent(opportunity.id)}`, - { - method: "PUT", - body: { status: nextStatus } - } - ); + const updated = await api.opportunities.updateStatus(opportunity.id, nextStatus); setOpportunity(updated); router.refresh(); } catch (updateError) { @@ -160,26 +61,26 @@ export function OpportunityDetail({ initialOpportunity, projectId }: Opportunity } const selectedStatus = isOpportunityStatus(opportunity.status) ? opportunity.status : "new"; - const backHref = projectId ? `/opportunities?projectId=${encodeURIComponent(projectId)}` : "/opportunities"; + const backHref = buildAllowedQueryHref("/opportunities", searchParams, { projectId }); return ( -
    -
    -
    +
    +
    +
    - Back to board + 返回看板 {opportunityStatusLabel(opportunity.status)} -

    {opportunity.title}

    +

    {opportunity.title}

    {opportunity.description ? ( -

    {opportunity.description}

    +

    {opportunity.description}

    ) : ( -

    No opportunity description available.

    +

    暂无机会描述。

    )}
    -
    +
    setMode(event.target.value as ProductionRunMode)} + value={mode} + > + {RUN_MODES.map((item) => ( + + ))} + + + + + +
    + +
    + {APPROVAL_LABELS.map((item) => ( + + ))} +
    + + + +
    + + + {canRun ? "门禁已就绪" : "等待批准条件"} + +
    + {actionState.status === "running" ?

    {actionState.message}

    : null} + {actionState.status === "success" ?

    {actionState.message}

    : null} + {actionState.status === "error" ? ( +

    + {actionState.message} +

    + ) : null} +
    + +
    +
    +

    + 当前状态 +

    +

    来自处理摘要与最近采集日志。

    +
    + {pageState.status === "loading" || pageState.status === "idle" ? ( + + ) : null} + {pageState.status === "error" ? ( + + ) : null} + {pageState.status === "ready" ? ( + <> +
    + + + + +
    +

    最近检查 {formatDateTime(pageState.runStatus.checked_at)}

    + + ) : null} +
    +
    + + {pageState.status === "ready" && pageState.runs.length > 0 ? ( +
    +

    StatusPlatformCollectedInsertedSkippedErrorRate limitReset atCreated at状态平台采集数量入库数量跳过数量错误信息速率限制重置时间创建时间
    - {log.status} - {platformLabel(log.platform)}{formatNumber(log.items_collected)}{formatNumber(log.items_inserted)}{formatNumber(log.items_skipped)}{sanitizeErrorMessage(log.error_message)}{formatNumber(log.rate_limit_remaining)}{formatDateTime(log.rate_limit_reset_at)}{formatDateTime(log.created_at)} + {formatStatusLabel(log.status)} + {platformLabel(log.platform)}{formatNumber(log.items_collected)}{formatNumber(log.items_inserted)}{formatNumber(log.items_skipped)}{sanitizeErrorMessage(log.error_message)}{formatNumber(log.rate_limit_remaining)}{formatDateTime(log.rate_limit_reset_at)}{formatDateTime(log.created_at)}
    {children}{children}{children}
    + + + + + + + + + + + + + + + {pageState.runs.map((run) => ( + + + + + + + + + + + + ))} + +
    状态阶段模式采集任务处理模式入库信号错误创建时间
    + + {formatStatusLabel(run.status)} + + {formatRunStageLabel(run.state)}{RUN_MODE_LABELS[run.mode] ?? run.mode}{run.collection_job_id ?? "-"}{formatProcessingModeLabel(run.processing_mode)}{formatNumber(run.items_inserted)}{formatNumber(run.total_signals)}{run.error_message ?? "-"}{formatDateTime(run.created_at)}
    +
    + ) : null} +
    + ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
    +

    {label}

    +

    {formatNumber(value)}

    +
    + ); +} + +function Th({ children }: { children: string }) { + return {children}; +} + +function mergeRuns(localRuns: ProductionRunListItem[], persistedRuns: ProductionRunListItem[]) { + const seen = new Set(); + return [...localRuns, ...persistedRuns].filter((run) => { + if (seen.has(run.id)) { + return false; + } + + seen.add(run.id); + return true; + }); +} + +function productionRunReadToListItem( + run: ProductionRunRead, + displayMode: ProductionRunMode +): ProductionRunListItem { + const collectionSummary = asRecord(run.result_summary.collection); + const processingSummary = asRecord(run.result_summary.processing); + + return { + id: run.id, + project_id: run.project_id ?? "", + created_at: run.created_at ?? run.started_at ?? new Date().toISOString(), + state: formatProductionRunStage(run.stage), + status: run.status, + mode: displayMode, + collection_job_id: typeof collectionSummary.job_id === "string" ? collectionSummary.job_id : null, + processing_mode: run.processing_mode, + items_inserted: numberValue(collectionSummary.items_inserted), + total_signals: numberValue(processingSummary.total_signals), + error_message: run.error_summary + }; +} + +function formatRunResult(run: ProductionRunRead): string { + if (run.status !== "success") { + return `${formatStatusLabel(run.status)}:${run.error_summary ?? "后端预检或生命周期门禁未通过。"}`; + } + + const processingSummary = asRecord(run.result_summary.processing); + return `运行完成:信号 ${formatNumber(numberValue(processingSummary.total_signals))} 条,阶段 ${formatRunStageLabel(formatProductionRunStage(run.stage))}。`; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function numberValue(value: unknown): number | null { + return typeof value === "number" ? value : null; +} + +function formatProductionRunStage(runStage: string): ProductionRunListItem["state"] { + if (runStage === "collect" || runStage === "process" || runStage === "review" || runStage === "report") { + return runStage; + } + + return runStage === "closeout" ? "closeout" : "review"; +} + +function formatRunStageLabel(stage: ProductionRunListItem["state"]): string { + const labels: Record = { + collect: "采集", + process: "处理", + review: "复核", + report: "报告", + closeout: "收口" + }; + + return labels[stage]; +} + +function formatProcessingModeLabel(mode: string | null): string { + if (!mode) { + return "-"; + } + + return PROCESSING_MODE_LABELS[mode] ?? mode; +} + +function formatActionError(error: unknown): string { + if (error instanceof ApiClientError) { + return `${error.code}${error.status ? ` (${error.status})` : ""}: ${error.message}`; + } + + return "未知生产运行错误。"; +} diff --git a/apps/web/components/reports/ReportsPage.tsx b/apps/web/components/reports/ReportsPage.tsx index 29900e4..9aaa208 100644 --- a/apps/web/components/reports/ReportsPage.tsx +++ b/apps/web/components/reports/ReportsPage.tsx @@ -1,11 +1,10 @@ "use client"; import { useSearchParams } from "next/navigation"; -import type { CSSProperties } from "react"; import { useEffect, useState } from "react"; import { api } from "../../lib/api"; import { formatDateTime } from "../../lib/format"; -import type { CsvReportResponse, MarkdownReportResponse } from "../../lib/types"; +import type { CsvReportResponse, MarkdownReportResponse, ReportRequest } from "../../lib/types"; import { Button } from "../ui/Button"; import { EmptyState } from "../ui/EmptyState"; import { ErrorState } from "../ui/ErrorState"; @@ -21,16 +20,24 @@ export function ReportsPage() { const searchParams = useSearchParams(); const projectId = searchParams.get("projectId"); const [state, setState] = useState({ status: "idle" }); + const [reportOptions, setReportOptions] = useState({ + days: "7", + minPainLevel: "70", + topClustersLimit: "10", + opportunitiesLimit: "10" + }); + const [lastExportPath, setLastExportPath] = useState(null); useEffect(() => { setState({ status: "idle" }); + setLastExportPath(null); }, [projectId]); if (!projectId) { return ( ); } @@ -40,7 +47,8 @@ export function ReportsPage() { setState({ status: "loading", format: "markdown" }); try { - const report = await api.reports.markdown(currentProjectId); + const report = await api.reports.markdown(currentProjectId, buildReportRequest(reportOptions)); + setLastExportPath(exportPath(fallbackMarkdownFilename(currentProjectId))); setState({ status: "markdown", report }); } catch (error) { setState({ status: "error", error }); @@ -51,12 +59,14 @@ export function ReportsPage() { setState({ status: "loading", format: "csv" }); try { - const report = await api.reports.csv(currentProjectId); + const report = await api.reports.csv(currentProjectId, buildReportRequest(reportOptions)); + const filename = report.filename || fallbackCsvFilename(currentProjectId); downloadBlob({ content: report.content, contentType: report.content_type || "text/csv;charset=utf-8", - filename: report.filename || fallbackCsvFilename(currentProjectId) + filename }); + setLastExportPath(exportPath(filename)); setState({ status: "csv", report }); } catch (error) { setState({ status: "error", error }); @@ -73,32 +83,92 @@ export function ReportsPage() { contentType: "text/markdown;charset=utf-8", filename: fallbackMarkdownFilename(currentProjectId) }); + setLastExportPath(exportPath(fallbackMarkdownFilename(currentProjectId))); } const loadingFormat = state.status === "loading" ? state.format : null; return ( -
    -
    -

    Reports

    -

    Report exports

    -

    - Export markdown and csv reports with source_url preserved from the backend response. -

    +
    +
    +
    +

    报告导出

    +

    报告导出

    +

    + 导出 Markdown 和 CSV 报告,并保留后端返回的 source_url。 +

    +
    -
    -
    +
    +
    + + + + +
    +
    +

    + 导出路径:{lastExportPath ?? exportPath(fallbackCsvFilename(currentProjectId))} +

    {state.status === "error" ? ( - + ) : null} {state.status === "markdown" ? ( -
    -
    -

    Markdown preview

    +
    +
    +

    Markdown 预览

    - Generated {formatDateTime(state.report.generated_at)} + 生成时间 {formatDateTime(state.report.generated_at)}