diff --git a/.env.example b/.env.example index ec5fa1f..9b13a17 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ VIETQR_ENABLED=true # Storage STORAGE_PROVIDER=supabase # supabase | memory SUPABASE_URL=https://xyz.supabase.co -SUPABASE_ANON_KEY=... +SUPABASE_SERVICE_ROLE_KEY=server-only-secret # Rate limit guards (optional overrides) MAX_CONCURRENT_RESEARCH=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b43b8af..5c3f7a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,5 +34,13 @@ jobs: - name: Test run: npm test + - name: Database integration tests + run: | + npx supabase start + eval "$(npx supabase status -o env)" + SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" SUPABASE_TEST_ANON_KEY="$ANON_KEY" npm run test:db + npx supabase stop --no-backup + - name: Build run: npm run build + diff --git a/.gitignore b/.gitignore index 01ab0a9..83272c7 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ next-env.d.ts .worktrees/ # temp fixtures -/tmp/ \ No newline at end of file +/tmp/ +supabase/.temp/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1720aaf..9b0f815 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # Dockerfile — PartnerIQ (Google Cloud Run / Production) # ═══════════════════════════════════════════════════════ -FROM node:20-alpine AS base +FROM node:22-alpine AS base # Step 1: Install dependencies FROM base AS deps diff --git a/README.md b/README.md index 299114b..106adce 100644 --- a/README.md +++ b/README.md @@ -1,535 +1,148 @@ # PartnerIQ (TechBridgeAI) 🚀 -> **AI-Powered Corporate Intelligence & Collaboration Intelligence Platform** -> Nền tảng thẩm định doanh nghiệp thông minh tự động: Thu thập dữ liệu đa nguồn độc lập, tổng hợp hồ sơ chuẩn hóa qua LLM, đánh giá điểm phù hợp hợp tác (Collaboration Fit Score), theo dõi biến động lịch sử (Diff Engine) và xuất báo cáo One-Pager PDF chuyên nghiệp. +> **AI-Powered Corporate Intelligence & Collaboration Fit Platform** +> Nền tảng thẩm định doanh nghiệp thông minh: Tự động thu thập dữ liệu đa nguồn từ Internet, chuẩn hóa hồ sơ 360° qua LLM, chấm điểm tiềm năng hợp tác kinh doanh (Fit Score), nhận diện biến động theo thời gian và xuất báo cáo PDF One-Pager chuyên nghiệp. [![CI Pipeline](https://github.com/devonxjz/TechBridgeAI/actions/workflows/ci.yml/badge.svg)](https://github.com/devonxjz/TechBridgeAI/actions/workflows/ci.yml) -[![Tests Passing](https://img.shields.io/badge/Tests-23%20Suites%20%7C%20136%20Passed-success?logo=vitest)](https://vitest.dev/) +[![Tests Passing](https://img.shields.io/badge/Tests-27%20Suites%20%7C%20207%20Passed-success?logo=vitest)](https://vitest.dev/) [![Next.js 16](https://img.shields.io/badge/Next.js-16%20(Turbopack)-black?logo=next.js)](https://nextjs.org/) [![LangGraph](https://img.shields.io/badge/Orchestration-LangGraph%20v1.4-blue?logo=langchain)](https://langchain.com/) +[![LangChain](https://img.shields.io/badge/Framework-LangChain-1C3C3C?logo=langchain)](https://langchain.com/) +[![OpenAI](https://img.shields.io/badge/LLM-OpenAI%20gpt--4o--mini-412991?logo=openai)](https://openai.com/) +[![Supabase](https://img.shields.io/badge/Database-Supabase%20PostgreSQL-3ECF8E?logo=supabase)](https://supabase.com) [![Langfuse](https://img.shields.io/badge/Observability-Langfuse%20Cloud-orange)](https://langfuse.com/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.x%20%2F%207.0.2-blue?logo=typescript)](https://www.typescriptlang.org/) -[![OpenAI](https://img.shields.io/badge/AI-OpenAI%20Structured%20Outputs-412991?logo=openai)](https://openai.com/) -[![Supabase](https://img.shields.io/badge/Storage-Supabase%20PostgreSQL-3ECF8E?logo=supabase)](https://supabase.com) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) --- -## 🖼️ Tổng Quan Kiến Trúc Hệ Thống (System Overview) +## 🖼️ Kiến Trúc Hệ Thống (System Architecture)
- PartnerIQ System Architecture Overview -

Hình 1: Kiến trúc tổng thể hệ sinh thái PartnerIQ (TechBridgeAI) — Tương tác đa nguồn, xử lý lõi AI, lưu trữ đa phiên bản và xuất bản tài liệu.

+ PartnerIQ System Architecture Overview +

Kiến trúc tổng thể hệ sinh thái PartnerIQ — Quy trình thu thập đa nguồn, điều phối LangGraph, xử lý AI, lưu trữ Supabase và xuất bản báo cáo.

--- -## 🌟 Tính Năng Nổi Bật - -* 🔄 **LangGraph Parallel StateGraph Orchestration:** - * Khởi tạo đồ thị trạng thái song song 5 luồng thu thập độc lập (`web_search`, `website`, `news`, `registry`, `linkedin`) với cơ chế fan-in chuẩn hóa bằng Zod state annotation. - * Giới hạn tối đa 6 câu truy vấn định danh (`buildResearchQueries`) và ngân sách gọi mô hình / token tiền trạm (`createResearchBudget`). - * Tự động cô lập bằng chứng không tin cậy bằng thẻ `` và áp dụng chính sách ưu tiên theo từng trường dữ liệu (Field-sensitive precedence). -* 🔭 **Langfuse Cloud Tracing & Privacy Minimization:** - * Giám sát toàn diện vòng đời đồ thị qua OpenTelemetry (`@langfuse/otel` & `@langfuse/langchain`). - * Tự động làm sạch dữ liệu nhạy cảm (API Keys, Bearer tokens, email cá nhân, số điện thoại, HTML thô) trước khi gửi telemetry ra ngoài. - * Tính điểm chất lượng tất định (`source_coverage`, `profile_schema_valid`, `profile_confidence`, `analysis_schema_valid`, `research_success`). -* 🌐 **Multi-source Research Pipeline (Thu thập đa nguồn thời gian thực):** - * 🔍 **Web Search:** Tích hợp Serper Google Search API và chỉ tổng hợp dữ liệu trả về từ nguồn thật. - * 🛡️ **Tiered Website Scraper (3 cấp độ tự phục hồi):** Chuỗi fallback `SafeDirect → Jina Reader → TinyFish` với cơ chế chống SSRF (Private IP/Localhost block), DNS Pinning, giới hạn luồng 1MB và bộ lọc HTML tuyến tính an toàn. - * 🏛️ **VietQR Official Business Registry:** Tra cứu trực tiếp thông tin doanh nghiệp qua Mã số thuế (MST) với in-memory caching (7 ngày), tự động fallback sang Aggregator Search khi API nghẽn. - * 📰 **Tin tức kinh doanh Việt Nam:** Tự động tìm kiếm các bài viết từ CafeF, Báo Đầu tư, VnExpress, Vietstock... - * 💼 **Bóc tách LinkedIn / Nhân sự:** Thu thập thông tin ban lãnh đạo và đội ngũ cốt cán (tự động bỏ qua khi không có URL). -* ⚡ **Real-time SSE Streaming & Edge/Vercel Safe:** - * API route tối giản `runtime = "nodejs"` với `maxDuration = 300` và hạn chót nội bộ 285s đảm bảo không bị ngắt quãng giữa chừng. - * Hủy bỏ luồng tức thì qua `AbortSignal` khi người dùng ngắt kết nối. -* 🧠 **OpenAI Structured Profile Builder:** Chuẩn hóa thông tin tự động bằng Zod Schema & Structured Outputs (Strict Mode), tính toán độ tin cậy (`overallConfidence`) theo trọng số từng nguồn. -* 📊 **Analyst Module & Collaboration Fit Score (0–100):** Đánh giá mức độ phù hợp hợp tác kinh doanh theo 5 tiêu chí chuẩn hóa: - * 🏢 **Phù hợp ngành (Industry Alignment - 30%)** - * 👥 **Tương thích quy mô (Company Size Match - 20%)** - * 📍 **Phù hợp địa lý (Geographic Relevance - 15%)** - * 💻 **Trưởng thành số (Digital Maturity - 15%)** - * 📈 **Hoạt động gần đây (Recent Activity - 20%)** -* 🔍 **"What Changed?" Diff Engine:** So sánh tự động giữa các phiên bản hồ sơ của một doanh nghiệp (v1 → v2), nhận diện biến động về nhân sự, địa chỉ, ngành nghề và quy mô. -* 🗄️ **Multi-Version Storage (Supabase PostgreSQL):** Lưu trữ lịch sử hồ sơ dạng JSONB, tối ưu hóa truy vấn và bảo toàn toàn bộ vết thay đổi. -* 📑 **Bộ Công Cụ Xuất Bản Báo Cáo Chuyên Nghiệp:** - * 📋 **Markdown & JSON Export:** Sao chép vào Clipboard hoặc tải file `.md` / `.json` ngay tức thì. - * 📄 **Client-side PDF One-Pager (A4 Portrait):** Tạo báo cáo 1 trang tóm tắt chuẩn doanh nghiệp tiếng Việt có dấu với `@react-pdf/renderer` qua Dynamic Import (Zero Server Overhead, tải font Noto Sans cục bộ, hoạt động offline). +## 💡 PartnerIQ Là Gì? (Dành Cho Người Mới Bắt Đầu) ---- - -## 🏛️ Lược Đồ Kiến Trúc & Class Diagram - -### 1. Kiến Trúc Phân Lớp (Hexagonal / Ports & Adapters Architecture) +Khi bạn muốn hợp tác với một đối tác hoặc doanh nghiệp mới, bạn thường mất hàng giờ tìm kiếm thông tin trên Google, tra cứu mã số thuế, đọc tin tức và phân tích rủi ro. **PartnerIQ tự động hóa toàn bộ quy trình này chỉ trong 3 bước đơn giản:** -Hệ thống tuân thủ nghiêm ngặt nguyên lý **Ports & Adapters**, tách biệt hoàn toàn giữa logic nghiệp vụ lõi (Deep Core Modules) và các dịch vụ bên ngoài (Infrastructure Adapters): - -```mermaid -graph TB - subgraph Presentation ["1. Presentation Layer (Next.js App Router)"] - UI["Web Dashboard & UI (React, TailwindCSS, Glassmorphism)"] - API["API Route: /api/research (Thin Glue & SSE Streaming)"] - end - - subgraph CoreModules ["2. Deep Core Modules (Domain Logic)"] - RM["ResearchModule (Multi-source Orchestrator)"] - PM["ProfileModule (LLM Structured Builder)"] - DE["DiffEngine (Profile Comparison & Change Tracker)"] - AM["AnalystModule (Collaboration Fit Score & Risk Engine)"] - PDF["PDFExportEngine (Client-side One-Pager Generator)"] - end - - subgraph Ports ["3. Ports & Seams (Interfaces)"] - PortLLM["LLMAdapter"] - PortSearch["SearchAdapter"] - PortScraper["ScraperAdapter"] - PortRegistry["RegistryAdapter"] - PortStorage["StorageAdapter"] - end - - subgraph Adapters ["4. Infrastructure Adapters"] - OpenAI["OpenAI (gpt-4o-mini)"] - Serper["Google Search (Serper API)"] - TieredScraper["Tiered Scraper (SafeDirect -> Jina -> TinyFish)"] - VietQR["VietQR Business Registry API"] - Supabase["Supabase PostgreSQL (JSONB) / Memory"] - end - - UI <-->|SSE Events / JSON| API - UI --> PDF - API --> RM - API --> PM - API --> AM - - RM --> PortSearch - RM --> PortScraper - RM --> PortRegistry - PM --> PortLLM - PM --> DE - AM --> PortLLM - API --> PortStorage - - PortLLM --> OpenAI - PortSearch --> Serper - PortScraper --> TieredScraper - PortRegistry --> VietQR - PortStorage --> Supabase +```text + 1. Nhập thông tin 2. AI thu thập & phân tích 3. Nhận báo cáo toàn diện +┌────────────────┐ ┌────────────────────────────┐ ┌────────────────────────────┐ +│ Tên công ty │ ──► │ • Quét 5 nguồn độc lập │ ──► │ • Hồ sơ 360° + Bằng chứng │ +│ (hoặc MST/Web) │ │ • Xử lý qua LangGraph & AI │ │ • Điểm Fit Score (0-100) │ +└────────────────┘ │ • Kiểm tra Cache Supabase │ │ • Tải file PDF 1 trang │ + └────────────────────────────┘ └────────────────────────────┘ ``` --- -### 2. Lược Đồ Class - Domain Entities & Models (Class Diagram 1) +## 🛠️ 5 Trụ Cột Công Nghệ Cốt Lõi (Core Technologies) -Lược đồ mô tả toàn bộ cấu trúc dữ liệu miền (Domain Models) được định kiểu chặt chẽ trong hệ thống: - -```mermaid -classDiagram - direction TB - - class CompanyInput { - +string name - +string website - +string taxId - +string linkedinUrl - +string[] additionalKeywords - } - - class RawFinding { - +SourceName source - +string url - +string content - +Date extractedAt - +number confidence - +Record metadata - } - - class CompanyProfile { - +string id - +number version - +Date createdAt - +CompanyInput input - +string officialName - +string[] tradingNames - +string taxId - +string[] industry - +string description - +number foundedYear - +Address headquarters - +string website - +Person[] keyPeople - +string[] products - +string[] markets - +CompanySize companySize - +RevenueRange revenue - +Activity[] recentActivities - +Date lastUpdated - +SourceCitation[] sources - +number overallConfidence - +boolean lowConfidence - } - - class Address { - +string street - +string city - +string province - +string country - } - - class Person { - +string name - +string title - +SourceName source - +number confidence - } - - class Activity { - +Date date - +string title - +string summary - +string url - +SourceName source - } - - class SourceCitation { - +SourceName source - +string url - +Date accessedAt - +string[] fieldsContributed - } - - class ProfileDiff { - +string companyId - +number fromVersion - +number toVersion - +FieldChange[] changes - +string summary - } - - class FieldChange { - +string field - +unknown oldValue - +unknown newValue - +string changeType - +string significance - } - - class AnalysisReport { - +string companyId - +Date generatedAt - +FitScore fitScore - +RiskFlag[] riskFlags - +SuggestedAction[] suggestedActions - +string executiveSummary - } - - class FitScore { - +number score - +string reasoning - +FitCriterion[] criteria - } - - class FitCriterion { - +string name - +number score - +number weight - +string reasoning - } - - class RiskFlag { - +string type - +string description - +string severity - +SourceName source - } - - class SuggestedAction { - +string action - +string priority - +string reasoning - } - - class PdfPayload { - +string companyName - +string taxId - +string[] industries - +string description - +number fitScore - +string fitReason - +PdfCriterion[] criteria - +string executiveSummary - +string[] risks - +string[] actions - +SourceItem[] sources - +string generatedAt - } - - CompanyProfile *-- CompanyInput : contains - CompanyProfile *-- Address : headquarters - CompanyProfile o-- Person : keyPeople - CompanyProfile o-- Activity : recentActivities - CompanyProfile o-- SourceCitation : sources - ProfileDiff o-- FieldChange : changes - AnalysisReport *-- FitScore : contains - FitScore o-- FitCriterion : criteria - AnalysisReport o-- RiskFlag : riskFlags - AnalysisReport o-- SuggestedAction : suggestedActions - CompanyProfile ..> PdfPayload : maps to - AnalysisReport ..> PdfPayload : maps to -``` +| Biểu tượng | Công nghệ | Vai trò & Cách ứng dụng trong hệ thống | +| :---: | :--- | :--- | +| 🕸️ | **LangGraph** | **Điều phối luồng công việc (StateGraph Orchestration):** Quản lý trạng thái đa luồng song song (Fan-out / Fan-in) qua 5 nguồn dữ liệu độc lập, kiểm soát hạn mức (Token / Query Budget) và xử lý lỗi mềm. | +| 🦜🔗 | **LangChain** | **Tương tác chuẩn hóa với LLM:** Sử dụng Zod Schemas để ép kiểu dữ liệu đầu ra nghiêm ngặt (Strict Structured Outputs), quản lý tin nhắn và streaming phản hồi theo thời gian thực. | +| ⚡ | **Next.js 16 (Turbopack)** | **Fullstack Dashboard & SSE Streaming:** Giao diện người dùng hiện đại, truyền dữ liệu Server-Sent Events (SSE) trực tiếp từ backend tới dashboard với độ trễ thấp. | +| 🗄️ | **Supabase (PostgreSQL)** | **Lưu trữ & Cache Thông Minh:** Quản lý lịch sử hồ sơ dạng JSONB, hỗ trợ cache read-through với khóa phân tán (`pg_advisory_xact_lock`) chống nghẽn và lưu vết thay đổi đa phiên bản. | +| 🔭 | **Langfuse Cloud** | **Giám sát & Tối ưu AI (Observability):** Ghi nhận toàn bộ Trace của đồ thị LangGraph, đo lường độ trễ, chi phí token, tự động làm sạch dữ liệu nhạy cảm và chấm điểm chất lượng nghiên cứu. | --- -### 3. Lược Đồ Class - Deep Modules & Infrastructure Ports/Adapters (Class Diagram 2) +## 🔄 Sơ Đồ Quy Trình Điều Phối (LangGraph StateGraph Workflow) -Lược đồ mô tả các Interface (Ports), các Deep Modules và các Concrete Adapters thực thi: +Dưới đây là sơ đồ chi tiết toàn bộ các **Node** và **Edge** trong đồ thị LangGraph điều phối quá trình nghiên cứu: ```mermaid -classDiagram - direction TB - - %% Ports (Interfaces) - class LLMAdapter { - <> - +complete(prompt: string, options?: LLMOptions) Promise~string~ - +completeStructured~T~(prompt: string, schema: ZodSchema~T~, options?: LLMOptions) Promise~T~ - +stream(prompt: string, options?: LLMOptions) AsyncGenerator~string~ - } - - class SearchAdapter { - <> - +search(query: string, options?: SearchOptions) Promise~SearchResult[]~ - } - - class ScraperAdapter { - <> - +extract(url: string) Promise~ScrapedContent~ - } - - class RegistryAdapter { - <> - +findByTaxId(taxId: string) Promise~RegistryRecord | null~ - } - - class StorageAdapter { - <> - +saveProfile(profile: CompanyProfile) Promise~void~ - +getProfile(companyId: string, version?: number) Promise~CompanyProfile | null~ - +getLatestProfile(companyId: string) Promise~CompanyProfile | null~ - +listProfiles() Promise~CompanyProfile[]~ - +saveDiff(diff: ProfileDiff) Promise~void~ - +getDiffs(companyId: string) Promise~ProfileDiff[]~ - } - - %% Deep Modules - class ResearchModule { - <> - +research(input: CompanyInput) AsyncGenerator~ResearchEvent~ - } - - class ProfileModule { - <> - +buildProfile(findings: RawFinding[], input: CompanyInput, existingId?: string, existingVersion?: number) Promise~CompanyProfile~ - +diffProfiles(current: CompanyProfile, previous: CompanyProfile) ProfileDiff - } - - class AnalystModule { - <> - +analyze(profile: CompanyProfile, context?: AnalysisContext) Promise~AnalysisReport~ - } - - %% Concrete Adapters - class OpenAILLMAdapter { - -OpenAI client - +complete() - +completeStructured() - +stream() - } - - class SerperSearchAdapter { - -string apiKey - +search() - } - - class TieredScraperAdapter { - -ScraperAdapter[] tiers - +extract(url: string) Promise~ScrapedContent~ - } - - class DirectScraperAdapter { - -UrlSafetyValidator validator - -number timeoutMs - -number maxBytes - +extract(url: string) Promise~ScrapedContent~ - } - - class JinaScraperAdapter { - -string apiKey - +extract(url: string) Promise~ScrapedContent~ - } - - class TinyFishScraperAdapter { - -string apiKey - +extract(url: string) Promise~ScrapedContent~ - } - - class VietQrRegistryAdapter { - -Map cache - -number ttlMs - +findByTaxId(taxId: string) Promise~RegistryRecord | null~ - } - - class SupabaseStorageAdapter { - -SupabaseClient client - +saveProfile() - +getProfile() - +getLatestProfile() - +saveDiff() - } - - class MemoryStorageAdapter { - -Map profiles - -Map diffs - +saveProfile() - +getProfile() - } - - %% Relationships & Implementations - LLMAdapter <|.. OpenAILLMAdapter : implements - SearchAdapter <|.. SerperSearchAdapter : implements - - ScraperAdapter <|.. TieredScraperAdapter : implements - ScraperAdapter <|.. DirectScraperAdapter : implements - ScraperAdapter <|.. JinaScraperAdapter : implements - ScraperAdapter <|.. TinyFishScraperAdapter : implements - TieredScraperAdapter o-- ScraperAdapter : contains fallback tiers - - RegistryAdapter <|.. VietQrRegistryAdapter : implements - - StorageAdapter <|.. SupabaseStorageAdapter : implements - StorageAdapter <|.. MemoryStorageAdapter : implements - - ResearchModule ..> SearchAdapter : uses - ResearchModule ..> ScraperAdapter : uses - ResearchModule ..> RegistryAdapter : uses - ProfileModule ..> LLMAdapter : uses - AnalystModule ..> LLMAdapter : uses +flowchart TD + Start([🚀 Bắt Đầu]) --> InputNode[📥 1. Nhận yêu cầu: Company Input] + + InputNode --> CacheCheck{🔍 Kiểm tra Cache?} + CacheCheck -- Cache Hit --> ReturnCache[⚡ Trả về Snapshot Cache có sẵn] + CacheCheck -- Cache Miss / Bypass --> FanOut[🔀 Điều phối song song 5 nguồn] + + subgraph SourcesGroup [🌐 5 Nguồn Thu Thập Dữ Liệu Độc Lập] + direction TB + S1["🔍 web_search
(Google Search qua Serper API)"] + S2["🌐 website
(Scraper 3 cấp: Direct ➔ Jina ➔ TinyFish)"] + S3["📰 news
(Tin tức báo chí CafeF, VnExpress, Vietstock)"] + S4["🏛️ registry
(Tra cứu Cổng ĐKKD & VietQR theo MST)"] + S5["💼 linkedin
(Thu thập thông tin nhân sự ban lãnh đạo)"] + end + + FanOut --> S1 + FanOut --> S2 + FanOut --> S3 + FanOut --> S4 + FanOut --> S5 + + S1 --> EvidenceNode[📑 2. prepare_evidence
Lọc dữ liệu & Bọc thẻ an toàn] + S2 --> EvidenceNode + S3 --> EvidenceNode + S4 --> EvidenceNode + S5 --> EvidenceNode + + EvidenceNode --> ProfileNode[🧠 3. build_profile
LLM tổng hợp hồ sơ chuẩn hóa 360°] + + ProfileNode --> DiffNode[🔄 4. build_diff
So sánh thay đổi với phiên bản cũ] + + DiffNode --> AnalyzeNode[📊 5. analyze
Chấm điểm FitScore 0-100 & Rủi ro] + + AnalyzeNode --> PersistNode[🗄️ 6. persist_snapshot
Lưu trữ Snapshot vào Supabase] + + PersistNode --> StreamOutput[⚡ 7. SSE Streaming & Cập nhật Dashboard] + + StreamOutput --> PDFExport[📄 8. Xuất Báo Cáo PDF One-Pager A4] + + ReturnCache --> StreamOutput + PDFExport --> End([🏁 Hoàn Thành]) + + classDef startEnd fill:#3B82F6,stroke:#1D4ED8,stroke-width:2px,color:#fff; + classDef nodeStyle fill:#F3F4F6,stroke:#9CA3AF,stroke-width:1.5px,color:#111827; + classDef sourceStyle fill:#ECFDF5,stroke:#10B981,stroke-width:1.5px,color:#065F46; + classDef aiStyle fill:#EEF2FF,stroke:#6366F1,stroke-width:1.5px,color:#3730A3; + classDef dbStyle fill:#FDF4FF,stroke:#D946EF,stroke-width:1.5px,color:#701A75; + + class Start,End startEnd; + class InputNode,EvidenceNode,StreamOutput,PDFExport,ReturnCache nodeStyle; + class S1,S2,S3,S4,S5 sourceStyle; + class ProfileNode,DiffNode,AnalyzeNode aiStyle; + class PersistNode dbStyle; ``` --- -### 4. Sequence Diagram - Luồng Xử Lý Dữ Liệu Thời Gian Thực (Data Flow & Streaming) +## 🌟 5 Nguồn Dữ Liệu Hoạt Động Như Thế Nào? -```mermaid -sequenceDiagram - autonumber - actor User as 👤 Người Dùng - participant UI as 💻 Next.js Client - participant API as ⚡ API Route (/api/research) - participant RM as 🔍 ResearchModule - participant Sources as 🌐 5 Data Sources - participant PM as 🧠 ProfileModule (LLM) - participant AM as 📊 AnalystModule (Fit Score) - participant DB as 🗄️ Supabase Storage - participant PDF as 📑 PDF Engine (Client) - - User->>UI: Nhập tên công ty / website / MST - UI->>API: POST /api/research (SSE Request) - API-->>UI: Event: research:start - - API->>RM: research(input) - loop Duyệt qua 5 nguồn dữ liệu - RM->>Sources: Tìm kiếm (Web, Scraper, VietQR, News, LinkedIn) - Sources-->>RM: Trả về dữ liệu thô (RawFinding) - RM-->>API: Yield: progress & finding - API-->>UI: SSE: research:progress & finding - end - RM-->>API: Complete (all findings) - - API-->>UI: Event: profile:building - API->>PM: buildProfile(findings, input) - PM-->>API: CompanyProfile (Structured) - API->>DB: getLatestProfile(companyId) - DB-->>API: Previous Profile (nếu có) - opt Có phiên bản trước - API->>PM: diffProfiles(current, previous) - PM-->>API: ProfileDiff - API->>DB: saveDiff(diff) - end - - API->>AM: analyze(profile, context) - AM-->>API: AnalysisReport (FitScore 0-100, Risks, Actions) - - API->>DB: saveProfile(profile) - API-->>UI: Event: profile:ready & analysis:ready & done - UI-->>User: Hiển thị giao diện Dashboard & Fit Score - - opt Người dùng click Xuất PDF - User->>UI: Bấm "Xuất PDF One-Pager" - UI->>PDF: mapToPdfPayload & renderAsync() - PDF-->>User: Tải xuống PartnerIQ_CompanyName_YYYY-MM-DD.pdf (A4) - end -``` +1. **🔍 Tìm kiếm web (`web_search`):** Sử dụng Serper Google Search API để tìm kiếm các bài viết, hồ sơ doanh nghiệp mới nhất trên Internet. +2. **🌐 Website công ty (`website`):** Trích xuất nội dung trang chủ và các trang giới thiệu (`/about`, `/products`), tự động bảo vệ trước các liên kết độc hại qua cơ chế **Safe Tiered Scraper (Direct ➔ Jina Reader ➔ TinyFish)**. +3. **📰 Tin tức truyền thông (`news`):** Quét các trang báo chí tài chính hàng đầu (CafeF, VnExpress, Vietstock, Báo Đầu tư) để phát hiện sự kiện nổi bật và dấu hiệu rủi ro. +4. **🏛️ Đăng ký kinh doanh (`registry`):** Tra cứu dữ liệu định danh pháp lý chính thức từ Cổng đăng ký doanh nghiệp quốc gia và VietQR qua Mã số thuế. +5. **💼 Mạng lưới nhân sự (`linkedin`):** Khám phá cấu trúc lãnh đạo, nhân sự cốt cán và quy mô đội ngũ. --- -## ⚙️ Cấu Hình & Biến Môi Trường (Configuration & Resilience) - -### File `.env.local` mẫu - -```dotenv -# ─── LLM Provider ─── -LLM_PROVIDER=openai -OPENAI_API_KEY=sk-... - -# ─── Search Provider ─── -SEARCH_PROVIDER=serper -SERPER_API_KEY=... - -# ─── Scraper Provider & Fallback Chain ─── -SCRAPER_PROVIDER=tiered # tiered | tinyfish -SCRAPER_DIRECT_ENABLED=true # Tier 1: Direct HTTP scraper + SSRF Guard -SCRAPER_JINA_ENABLED=true # Tier 2: Jina AI Reader -SCRAPER_TINYFISH_ENABLED=true # Tier 3: TinyFish API -JINA_API_KEY= -TINYFISH_API_KEY= -SCRAPER_TIMEOUT_MS=8000 -SCRAPER_MAX_RESPONSE_BYTES=1048576 # Giới hạn stream 1MB -SCRAPER_MAX_REDIRECTS=3 -MAX_SCRAPE_PAGES_PER_RESEARCH=5 - -# ─── Registry Provider (VietQR) ─── -VIETQR_ENABLED=true # Tra cứu MST chính thức với 7-day memory cache - -# ─── Storage Provider (supabase | memory) ─── -STORAGE_PROVIDER=supabase -SUPABASE_URL=https://xyz.supabase.co -SUPABASE_ANON_KEY=eyJ... +## 📊 Tiêu Chí Đánh Giá Điểm Hợp Tác (Collaboration Fit Score 0–100) -# ─── Resource & Rate Limit Guards ─── -MAX_CONCURRENT_RESEARCH=1 -SOURCE_TIMEOUT_MS=30000 -MAX_RESEARCH_PER_DAY=50 -MAX_TOKENS_PER_DAY=500000 -``` +Hệ thống chấm điểm doanh nghiệp dựa trên **5 tiêu chí chuẩn hóa**: -### Cơ Chế Fallback & Tự Phục Hồi (Circuit Breakers) +* 🏢 **Phù hợp ngành nghề (Industry Alignment - 30%):** Đánh giá sự tương đồng trong lĩnh vực hoạt động. +* 👥 **Tương thích quy mô (Company Size Match - 20%):** Đánh giá năng lực tiếp nhận và quy mô nhân sự. +* 📍 **Vị trí địa lý (Geographic Relevance - 15%):** Khả năng triển khai thuận lợi theo vùng miền. +* 💻 **Mức độ số hóa (Digital Maturity - 15%):** Đánh giá mức độ ứng dụng công nghệ và hiện diện trực tuyến. +* 📈 **Hoạt động gần đây (Recent Activity - 20%):** Các dự án mới, sự kiện mở rộng hoặc phát triển trong 6–12 tháng qua. -| Tình huống sự cố | Cơ chế tự động xử lý | Trạng thái hệ thống | -| :--- | :--- | :--- | -| **Direct Scraper bị chặn / WAF** | Tự động chuyển tier sang **Jina Reader → TinyFish** | Không gián đoạn | -| **Jina Reader 429 (Rate Limit)** | Bỏ qua Jina, fallback tức thì sang **TinyFish** | Không gián đoạn | -| **Thiếu API Key Jina/TinyFish** | Tự động bypass tier thiếu key mà không gây lỗi runtime | Tự thích ứng | -| **Target URL là Local IP / Private** | Chặn ngay tại `UrlSafetyValidator` (SSRF Protection) | An toàn tuyệt đối | -| **VietQR API quá tải / lỗi mạng** | Fallback sang tra cứu qua **Aggregator & Google Search** | Bền bỉ | -| **Supabase không khả dụng** | Fallback sang **In-Memory Storage** cho môi trường dev/test | Sẵn sàng chạy offline | +> **Tính năng Bằng chứng thực tế (Real-world Evidence):** Người dùng có thể click vào bất kỳ thẻ thông tin nào trên giao diện (Tiêu chí FitScore, Rủi ro, Nhân sự, Mã số thuế, Trụ sở...) để mở tab mới kiểm chứng ngay dữ liệu từ nguồn gốc! --- -## 🛠️ Cài Đặt & Khởi Chạy Nhanh (Getting Started) +## 🚀 Hướng Dẫn Cài Đặt & Chạy Nhanh (Quick Start) -### 1. Yêu cầu môi trường -* **Node.js**: Phiên bản `>= 18.17.0` (khuyến nghị Node 20 LTS hoặc 24). -* **Trình quản lý gói**: `npm` hoặc `pnpm`. +### 1. Yêu cầu hệ thống +* **Node.js**: Phiên bản `>= 18.17.0` (khuyến nghị Node 20 LTS hoặc Node 24). +* **NPM / PNPM**. ### 2. Cài đặt các gói phụ thuộc ```bash @@ -543,18 +156,47 @@ Tạo file `.env.local` từ file mẫu: ```bash cp .env.example .env.local ``` -*(Điền các API Key cần thiết như `OPENAI_API_KEY`, `SERPER_API_KEY`, `SUPABASE_URL`,...)* -### 4. Khởi chạy máy chủ phát triển +Điền các khóa API cơ bản: +```dotenv +# LLM Provider +LLM_PROVIDER=openai +OPENAI_API_KEY=sk-... + +# Search Provider +SEARCH_PROVIDER=serper +SERPER_API_KEY=... + +# Storage (Supabase hoặc Memory) +STORAGE_PROVIDER=supabase +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=eyJ... +SUPABASE_SERVICE_ROLE_KEY=eyJ... + +# Langfuse Observability (Tùy chọn) +LANGFUSE_ENABLED=true +LANGFUSE_PUBLIC_KEY=pk-... +LANGFUSE_SECRET_KEY=sk-... +LANGFUSE_BASE_URL=https://cloud.langfuse.com +``` + +### 4. Khởi chạy ứng dụng ```bash npm run dev ``` -Mở trình duyệt và truy cập [http://localhost:3000](http://localhost:3000). +Truy cập [http://localhost:3000](http://localhost:3000) trên trình duyệt để sử dụng. ---- +### 5. Kiểm thử hệ thống +```bash +npm run test # Chạy toàn bộ 27 test suites với Vitest +npm run lint # Kiểm tra chuẩn mã nguồn ESLint +npm run typecheck # Kiểm tra kiểu TypeScript +npm run build # Biên dịch production build với Turbopack +``` +--- -## 📄 Bản Quyền & Giấy Phép (License) +## 📄 Giấy Phép & Bản Quyền (License) -Dự án được phân phối dưới giấy phép **[MIT License](LICENSE)**. -Phát triển bởi đội ngũ **PartnerIQ / TechBridgeAI** tham dự **Google AI Hackathon 2026**. +Dự án được phân phối dưới giấy phép **[MIT License](LICENSE)**. +Phát triển bởi đội ngũ **PartnerIQ / TechBridgeAI**. diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/README.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/README.md new file mode 100644 index 0000000..8924bbf --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/README.md @@ -0,0 +1,50 @@ +# Supabase Research Cache Sprint Roadmap + +**Spec:** [`../../specs/2026-08-26-supabase-research-cache-design.md`](../../specs/2026-08-26-supabase-research-cache-design.md) + +This roadmap splits the approved cache design into dependency-ordered, +independently reviewable deliverables. A sprint is a technical delivery slice, +not a calendar estimate. Execute and review one sprint before starting its +successor. + +## Sprint sequence + +| Sprint | Deliverable | Depends on | Completion gate | +|---|---|---|---| +| [01 — Contracts and normalization](./sprint-01-contracts-and-normalization.md) | Typed request/SSE/snapshot contracts, safe identity normalization, pure lookup decisions | Approved spec | Focused unit tests, typecheck, Node 22 build | +| [02 — Supabase schema and storage](./sprint-02-supabase-schema-and-storage.md) | Identity schema, transactional RPCs, complete-snapshot storage adapter | Sprint 01 | Local database reset, adapter tests, two-client concurrency test | +| [03 — Server read-through flow](./sprint-03-server-read-through-flow.md) | Cache-first route, lazy paid providers, canonical workflow identity, atomic persist | Sprint 02 | Route/workflow tests prove hit/miss/conflict/error behavior | +| [04 — Client suggestions and refresh](./sprint-04-client-suggestions-and-refresh.md) | Safe company confirmation, cache metadata, manual refresh UI | Sprint 03 | State-transition tests plus visual before/after verification | +| [05 — Telemetry and release hardening](./sprint-05-telemetry-and-release-hardening.md) | HMAC telemetry, remaining negative-path coverage, advisors and release checks | Sprint 04 | Full test/lint/typecheck/build and database advisor pass | + +## Dependency flow + +```text +contracts → database/storage → server/workflow → client/UI → hardening/release +``` + +## Cross-sprint constraints + +- Supabase remains the only shared persistent cache. Do not add localStorage, + Redis, or an in-process LRU. +- Cache entries do not expire. Only explicit refresh creates a new version. +- Tax ID may auto-match; a unique domain may auto-match; a normalized name + only produces suggestions. +- Never trust a client-provided company ID without rebinding it to the current + normalized input. +- Never auto-merge identities. +- A cache hit, invalid selection, identity conflict, or unavailable cache must + not construct or call LLM, Serper, or scraper adapters. +- Every started SSE stream ends with `done`, including fatal failures. +- Runtime-validate cached JSONB before returning it. +- Use `pg_advisory_xact_lock`, never a session advisory lock. +- Use HMAC-SHA256 for telemetry fingerprints; never emit raw tax IDs/domains. +- No refresh rate limiter is added in these sprints. The spec records it as a + later security/cost control. + +## Execution rule + +Each sprint document is a standalone implementation plan. The executor reads +the approved spec and the selected sprint only, performs its test-first tasks, +and stops at that sprint's review gate. Do not batch multiple sprint commits +without review. diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-01-contracts-and-normalization.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-01-contracts-and-normalization.md new file mode 100644 index 0000000..9591b98 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-01-contracts-and-normalization.md @@ -0,0 +1,490 @@ +# Sprint 01 — Cache Contracts and Normalization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Establish the runtime-validated request, SSE, snapshot, normalization, +and pure cache-decision contracts without changing the research route behavior. + +**Architecture:** Add domain contracts to `src/lib/types.ts` and one focused +cache module containing pure normalization, snapshot validation, and lookup +decision logic. No Supabase I/O belongs in this sprint. Upgrade the production +container to Node.js 22 because current Supabase client releases no longer +support Node.js 20. + +**Tech Stack:** Next.js 16.3.2, TypeScript, Zod 4.4.3, Vitest 4.1.11, Node.js 22, +platform `URL` and `crypto.randomUUID()` APIs. + +**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md` + +## Global Constraints + +- Do not change the current `/api/research` runtime flow in this sprint. +- Do not add a cache dependency, localStorage, Redis, or an LRU. +- Preserve Vietnamese diacritics and legal suffixes in normalized names. +- A normalized name never creates an automatic hit. +- Runtime schemas must reject malformed cached JSONB instead of casting it. +- Do not emit raw tax IDs or domains to logs or telemetry. +- Match the existing Zod/type style in `src/lib/types.ts`. +- Read `node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md` + before later route work; no Next.js route change occurs in this sprint. +- Stage only files named by each task. + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `Dockerfile` | Modify | Move production runtime from Node 20 to Node 22 | +| `src/lib/types.ts` | Modify | Request, cache, snapshot, and SSE runtime contracts | +| `src/modules/cache/index.ts` | Create | Pure identity normalization, snapshot validation, and cache decision logic | +| `tests/unit/types-validation.test.ts` | Modify | Request-union and complete-snapshot schema coverage | +| `tests/unit/research-cache.test.ts` | Create | Normalization and lookup decision coverage | + +### Task 1: Define request and SSE contracts + +**Files:** + +- Modify: `src/lib/types.ts` +- Modify: `tests/unit/types-validation.test.ts` + +**Interfaces:** + +- Consumes: existing `CompanyInputSchema`, `CompanyProfile`, `ProfileDiff`, + `AnalysisReport`, and `StreamEvent`. +- Produces: + +```ts +export const CacheActionSchema: z.ZodType< + | { action: "select"; companyId: string } + | { action: "refresh"; companyId: string } + | { action: "bypass" } +>; + +export const ResearchRequestSchema: z.ZodType<{ + input: CompanyInput; + cache?: z.infer; +}>; + +export type ResearchErrorCode = + | "identity_conflict" + | "cache_invalid" + | "persist_failed" + | "research_failed"; + +export type CacheHitMatchedBy = "tax_id" | "domain" | "selected"; +``` + +- [ ] **Step 1: Write failing request-union tests** + +Append these cases to `tests/unit/types-validation.test.ts`: + +```ts +import { + ResearchRequestSchema, + type ResearchRequest, +} from "@/lib/types"; + +it("accepts default, select, refresh, and bypass research requests", () => { + const requests: ResearchRequest[] = [ + { input: { name: "FPT" } }, + { input: { name: "FPT" }, cache: { action: "select", companyId: "fpt" } }, + { input: { name: "FPT" }, cache: { action: "refresh", companyId: "fpt" } }, + { input: { name: "FPT" }, cache: { action: "bypass" } }, + ]; + + expect(requests.every((request) => ResearchRequestSchema.safeParse(request).success)) + .toBe(true); +}); + +it("rejects cache actions with missing or unexpected company IDs", () => { + expect( + ResearchRequestSchema.safeParse({ + input: { name: "FPT" }, + cache: { action: "select" }, + }).success, + ).toBe(false); + expect( + ResearchRequestSchema.safeParse({ + input: { name: "FPT" }, + cache: { action: "bypass", companyId: "injected" }, + }).success, + ).toBe(false); +}); +``` + +- [ ] **Step 2: Run the request tests and confirm the missing exports fail** + +Run: `npm test -- tests/unit/types-validation.test.ts` + +Expected: FAIL because `ResearchRequestSchema` and `ResearchRequest` do not +exist. + +- [ ] **Step 3: Implement the request schemas and stream-event additions** + +Add to `src/lib/types.ts`: + +```ts +export const CacheActionSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("select"), companyId: z.string().min(1) }).strict(), + z.object({ action: z.literal("refresh"), companyId: z.string().min(1) }).strict(), + z.object({ action: z.literal("bypass") }).strict(), +]); + +export const ResearchRequestSchema = z.object({ + input: CompanyInputSchema, + cache: CacheActionSchema.optional(), +}).strict(); + +export type ResearchRequest = z.infer; +export type ResearchErrorCode = + | "identity_conflict" + | "cache_invalid" + | "persist_failed" + | "research_failed"; +export type CacheHitMatchedBy = "tax_id" | "domain" | "selected"; + +export interface CacheSuggestion { + companyId: string; + officialName: string; + taxId?: string; + domain?: string; + lastSyncedAt: string; +} +``` + +Extend `StreamEvent` with `cache:hit` and `cache:suggestions`, and extend the +existing error payload with optional `code: ResearchErrorCode`. Use the exact +payloads from spec section 8. + +- [ ] **Step 4: Run focused validation and type checks** + +Run: + +```bash +npm test -- tests/unit/types-validation.test.ts +npm run typecheck +``` + +Expected: tests and typecheck pass. + +- [ ] **Step 5: Commit the request boundary** + +```bash +git add src/lib/types.ts tests/unit/types-validation.test.ts +git commit -m "feat(cache): define cache request contracts" +``` + +### Task 2: Add complete snapshot runtime schemas + +**Files:** + +- Modify: `src/lib/types.ts` +- Modify: `tests/unit/types-validation.test.ts` + +**Interfaces:** + +- Produces: + +```ts +export interface ResearchSnapshot { + profile: CompanyProfile; + report: AnalysisReport; + diff: ProfileDiff | null; + lastSyncedAt: string; +} + +export const ResearchSnapshotSchema: z.ZodType; +``` + +- [ ] **Step 1: Write failing complete/corrupt snapshot tests** + +Use the existing valid profile fixtures or construct the minimum full objects: + +```ts +it("parses a complete research snapshot and restores dates", () => { + const result = ResearchSnapshotSchema.parse({ + profile: validProfileJson, + report: validReportJson, + diff: null, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }); + + expect(result.profile.lastUpdated).toBeInstanceOf(Date); + expect(result.report.generatedAt).toBeInstanceOf(Date); +}); + +it("rejects mismatched and incomplete snapshots", () => { + expect(() => ResearchSnapshotSchema.parse({ + profile: validProfileJson, + report: { ...validReportJson, companyId: "other-company" }, + diff: null, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + })).toThrow(); +}); +``` + +Define `validProfileJson` with every current `CompanyProfile` field and +`validReportJson` with every current `AnalysisReport` field. Do not cast a +partial object to bypass the schema. + +- [ ] **Step 2: Run and verify the schema test fails** + +Run: `npm test -- tests/unit/types-validation.test.ts` + +Expected: FAIL because `ResearchSnapshotSchema` is absent. + +- [ ] **Step 3: Implement runtime schemas that mirror the domain types** + +Add Zod schemas for `Address`, `Person`, `Activity`, `SourceCitation`, +`CompanyProfile`, `FieldChange`, `ProfileDiff`, `FitScore`, `RiskFlag`, +`SuggestedAction`, `AnalysisReport`, and `ResearchSnapshot`. Use +`z.coerce.date()` for persisted date values, numeric bounds already enforced by +the analyst/profile modules, and `.strict()` on trust-boundary objects. + +Add this final cross-object validation: + +```ts +export const ResearchSnapshotSchema = z.object({ + profile: CompanyProfileSchema, + report: AnalysisReportSchema, + diff: ProfileDiffSchema.nullable(), + lastSyncedAt: z.string().datetime(), +}).strict().superRefine((snapshot, ctx) => { + if (snapshot.report.companyId !== snapshot.profile.id) { + ctx.addIssue({ + code: "custom", + path: ["report", "companyId"], + message: "Analysis report companyId must match profile id", + }); + } + if ( + snapshot.diff && + (snapshot.diff.companyId !== snapshot.profile.id || + snapshot.diff.toVersion !== snapshot.profile.version) + ) { + ctx.addIssue({ + code: "custom", + path: ["diff"], + message: "Profile diff must match profile id and version", + }); + } +}); +``` + +- [ ] **Step 4: Run validation tests and typecheck** + +Run: + +```bash +npm test -- tests/unit/types-validation.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit runtime snapshot validation** + +```bash +git add src/lib/types.ts tests/unit/types-validation.test.ts +git commit -m "feat(cache): validate cached research snapshots" +``` + +### Task 3: Normalize identity and decide cache outcomes + +**Files:** + +- Create: `src/modules/cache/index.ts` +- Create: `tests/unit/research-cache.test.ts` + +**Interfaces:** + +```ts +export interface NormalizedCompanyIdentity { + taxId: string | null; + domain: string | null; + name: string; +} + +export interface IdentityCandidate { + companyId: string; + taxId: string | null; + domain: string | null; + name: string; +} + +export type CacheDecision = + | { kind: "hit"; companyId: string; matchedBy: "tax_id" | "domain" } + | { kind: "suggestions"; companyIds: string[] } + | { kind: "miss" } + | { kind: "conflict"; taxCompanyId: string; domainCompanyIds: string[] }; + +export function normalizeCompanyIdentity(input: CompanyInput): NormalizedCompanyIdentity; +export function decideCacheLookup( + identity: NormalizedCompanyIdentity, + candidates: readonly IdentityCandidate[], +): CacheDecision; +``` + +- [ ] **Step 1: Write failing normalization tests** + +```ts +it("normalizes tax ID, domain, and Vietnamese name without dropping legal suffixes", () => { + expect(normalizeCompanyIdentity({ + name: " CÔNG TY CP Ánh Dương ", + taxId: "0101-245.486", + website: "https://WWW.Example.VN:443/about?q=1", + })).toEqual({ + taxId: "0101245486", + domain: "example.vn", + name: "công ty cp ánh dương", + }); +}); + +it("rejects a malformed supplied tax ID", () => { + expect(() => normalizeCompanyIdentity({ name: "FPT", taxId: "abc" })) + .toThrow("Mã số thuế phải có 10 hoặc 13 chữ số"); +}); +``` + +- [ ] **Step 2: Write failing decision-table tests** + +Cover these exact cases: + +```ts +expect(decideCacheLookup(withTaxAndDomain, candidatesForSameCompany)).toEqual({ + kind: "hit", companyId: "company-a", matchedBy: "tax_id", +}); +expect(decideCacheLookup(withConflictingKeys, conflictingCandidates)).toEqual({ + kind: "conflict", + taxCompanyId: "company-a", + domainCompanyIds: ["company-b"], +}); +expect(decideCacheLookup(domainOnly, twoDomainCandidates)).toEqual({ + kind: "suggestions", companyIds: ["company-a", "company-b"], +}); +expect(decideCacheLookup(nameOnly, oneNameCandidate)).toEqual({ + kind: "suggestions", companyIds: ["company-a"], +}); +expect(decideCacheLookup(nameOnly, [])).toEqual({ kind: "miss" }); +``` + +- [ ] **Step 3: Run and verify the new test fails** + +Run: `npm test -- tests/unit/research-cache.test.ts` + +Expected: FAIL because `@/modules/cache` does not exist. + +- [ ] **Step 4: Implement normalization with platform APIs** + +Use these rules directly: + +```ts +const TAX_ID_PATTERN = /^\d{10}(?:\d{3})?$/; + +function normalizeTaxId(value?: string): string | null { + if (!value) return null; + const normalized = value.trim().replace(/[\s.-]/g, ""); + if (!TAX_ID_PATTERN.test(normalized)) { + throw new Error("Mã số thuế phải có 10 hoặc 13 chữ số"); + } + return normalized; +} + +function normalizeDomain(website?: string): string | null { + if (!website) return null; + return new URL(website).hostname + .toLowerCase() + .replace(/\.$/, "") + .replace(/^www\./, ""); +} + +function normalizeName(name: string): string { + return name.normalize("NFKC").trim().toLocaleLowerCase("vi-VN") + .replace(/\s+/g, " "); +} +``` + +Implement `decideCacheLookup` as the spec decision table. Sort and deduplicate +suggestion IDs before returning them so database row order cannot affect output. + +- [ ] **Step 5: Run cache tests and typecheck** + +Run: + +```bash +npm test -- tests/unit/research-cache.test.ts tests/unit/types-validation.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit pure cache behavior** + +```bash +git add src/modules/cache/index.ts tests/unit/research-cache.test.ts +git commit -m "feat(cache): normalize and resolve identities" +``` + +### Task 4: Align the production runtime with supported Node.js + +**Files:** + +- Modify: `Dockerfile` + +**Interfaces:** + +- Consumes: the existing multi-stage Docker build. +- Produces: the same image layout running Node.js 22 Alpine. + +- [ ] **Step 1: Confirm the current base is unsupported** + +Read `Dockerfile` and confirm it currently contains: + +```dockerfile +FROM node:20-alpine AS base +``` + +The Supabase 2026 changelog states current client libraries dropped Node.js 20 +support. CI already uses Node.js 22. + +- [ ] **Step 2: Make the one-line runtime update** + +```dockerfile +FROM node:22-alpine AS base +``` + +- [ ] **Step 3: Verify production compilation** + +Run: + +```bash +npm run typecheck +npm run build +``` + +Expected: both commands pass under the development environment; the Docker +stages remain otherwise unchanged. + +- [ ] **Step 4: Commit the runtime prerequisite** + +```bash +git add Dockerfile +git commit -m "chore(runtime): move production to node 22" +``` + +## Sprint 01 review gate + +Run: + +```bash +npm test -- tests/unit/types-validation.test.ts tests/unit/research-cache.test.ts +npm run lint +npm run typecheck +npm run build +git status --short +``` + +Expected: all checks pass and the worktree contains no uncommitted Sprint 01 +files. Review the exported types before starting Sprint 02; later sprint plans +use these names exactly. diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-02-supabase-schema-and-storage.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-02-supabase-schema-and-storage.md new file mode 100644 index 0000000..4b16629 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-02-supabase-schema-and-storage.md @@ -0,0 +1,573 @@ +# Sprint 02 — Supabase Schema and Storage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the canonical company identity schema, transactional database +functions, complete-snapshot persistence, and matching memory/Supabase storage +behavior without changing the research route. + +**Architecture:** Keep `StorageAdapter` as the existing persistence seam and +deepen it with identity/cache methods. Supabase implements multi-statement +identity creation and snapshot persistence through transaction-scoped RPCs; +the memory adapter provides deterministic parity for route/workflow tests. + +**Tech Stack:** PostgreSQL 17-compatible SQL, Supabase Data API/PostgREST, +`@supabase/supabase-js` 2.112.x, pinned Supabase CLI 2.115.0, TypeScript, +Vitest 4.1.11. + +**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md` + +## Global Constraints + +- Sprint 01 types and function names are fixed inputs. +- Use `pg_advisory_xact_lock`; session-level advisory locks are forbidden. +- Tax IDs are unique only when non-null; domains and names are not unique. +- Never auto-merge identities or overwrite conflicting identity metadata. +- Persist profile, report, diff, and identity metadata in one transaction. +- Cache reads accept only profile rows with non-null `analysis_report`. +- Database functions use `SECURITY INVOKER`, an empty search path, and fully + qualified relation names. +- Revoke Data API access from `anon` and `authenticated`; grant only the + server-side `service_role` the required table/function access. +- Never expose `SUPABASE_SERVICE_ROLE_KEY` through `NEXT_PUBLIC_*` or client + modules. +- Create migrations with `supabase migration new`; do not hand-name migration + files. +- Stage only files named by each task. + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `package.json` | Modify | Pin Supabase CLI and add the database test command | +| `package-lock.json` | Modify | Lock CLI binaries and package graph | +| `supabase/config.toml` | Create via CLI | Local Supabase project configuration | +| `supabase/migrations/_research_cache.sql` | Create via CLI | Existing-database migration and RPC definitions | +| `supabase/schema.sql` | Modify | Canonical fresh-project schema matching the migration | +| `.env.example` | Modify | Server-only service-role configuration | +| `src/adapters/storage/types.ts` | Modify | Identity and complete-snapshot storage interface | +| `src/adapters/storage/memory.ts` | Modify | Test/development implementation | +| `src/adapters/storage/supabase.ts` | Modify | RPC and complete-snapshot implementation | +| `src/config/index.ts` | Modify | Require the server-only key for Supabase storage | +| `tests/unit/adapters.test.ts` | Modify | Memory cache/storage parity | +| `tests/unit/supabase-storage.test.ts` | Modify | RPC mapping and service-key validation | +| `tests/integration/supabase-cache-concurrency.test.ts` | Create | Real transaction/concurrency verification | +| `.github/workflows/ci.yml` | Modify | Run local Supabase database checks in CI | + +### Task 1: Pin and initialize the database toolchain + +**Files:** + +- Modify: `package.json` +- Modify: `package-lock.json` +- Create via CLI: `supabase/config.toml` + +**Interfaces:** + +- Consumes: Node.js 22 from Sprint 01 and Docker on developer/CI hosts. +- Produces: reproducible `npx supabase` commands at version 2.115.0. + +- [ ] **Step 1: Install the stable CLI version verified for this plan** + +Run: + +```bash +npm install --save-dev --save-exact supabase@2.115.0 +npx supabase --version +npx supabase migration new --help +npx supabase db advisors --help +``` + +Expected: version `2.115.0`; help lists `migration new` and local database +advisor support. Stop and update this sprint document if the pinned commands do +not match the installed help. + +- [ ] **Step 2: Initialize local Supabase configuration** + +Run: + +```bash +npx supabase init +``` + +Expected: `supabase/config.toml` is created without replacing +`supabase/schema.sql`. + +- [ ] **Step 3: Add an explicit database-test script** + +Add to `package.json`: + +```json +"test:db": "vitest run tests/integration/supabase-cache-concurrency.test.ts" +``` + +- [ ] **Step 4: Verify package integrity and commit** + +Run: + +```bash +npm ci +npx supabase --version +``` + +Expected: clean install and CLI `2.115.0`. + +```bash +git add package.json package-lock.json supabase/config.toml +git commit -m "chore(db): pin supabase cli" +``` + +### Task 2: Add identity and complete-snapshot schema + +**Files:** + +- Create via CLI: `supabase/migrations/_research_cache.sql` +- Modify: `supabase/schema.sql` + +**Interfaces:** + +- Produces tables/indexes described in spec section 4 and these RPCs: + +```sql +public.lookup_company_identities(text, text, text) +public.resolve_company_identity(text, text, text, text) +public.persist_research_snapshot(text, text, text, text, integer, jsonb, jsonb, jsonb) +``` + +- [ ] **Step 1: Create the migration through the CLI** + +Run: + +```bash +npx supabase migration new research_cache +``` + +Expected: CLI prints the exact new path under `supabase/migrations`. Use that +printed path for every remaining migration edit and commit; do not rename it. + +- [ ] **Step 2: Add the identity table and profile/diff constraints** + +Write these statements into the generated migration and mirror them in +`supabase/schema.sql` for fresh projects: + +```sql +create table if not exists public.company_identities ( + id text primary key, + tax_id text, + normalized_domain text, + normalized_name text not null, + created_at timestamptz not null default timezone('utc'::text, now()), + updated_at timestamptz not null default timezone('utc'::text, now()) +); + +create unique index if not exists idx_company_identities_tax_id + on public.company_identities (tax_id) + where tax_id is not null; +create index if not exists idx_company_identities_domain + on public.company_identities (normalized_domain); +create index if not exists idx_company_identities_name + on public.company_identities (normalized_name); + +alter table public.company_profiles + add column if not exists analysis_report jsonb; + +create index if not exists idx_company_profiles_complete + on public.company_profiles (id, version desc) + where analysis_report is not null; +``` + +Backfill `company_identities` from the latest row per existing profile ID before +adding foreign keys. Use `data->>'taxId'`, `data->>'website'`, and +`data->>'officialName'` with the same digit/domain/lowercase whitespace rules +as Sprint 01. Invalid legacy tax IDs become null; never fail the migration on +one malformed JSONB value. + +Then add foreign keys: + +```sql +alter table public.company_profiles + add constraint company_profiles_identity_fk + foreign key (id) references public.company_identities(id); + +alter table public.company_diffs + add constraint company_diffs_identity_fk + foreign key (company_id) references public.company_identities(id); +``` + +Guard each named constraint with a `pg_constraint` existence check so rerunning +the canonical schema is idempotent. + +- [ ] **Step 3: Replace broad public policies with server-only access** + +Drop the two existing “Allow anon read/write” policies, enable RLS on all three +tables, revoke table access from `anon`/`authenticated`, and grant the minimum +table privileges to `service_role`: + +```sql +drop policy if exists "Allow anon read/write company_profiles" + on public.company_profiles; +drop policy if exists "Allow anon read/write company_diffs" + on public.company_diffs; + +alter table public.company_identities enable row level security; +alter table public.company_profiles enable row level security; +alter table public.company_diffs enable row level security; + +revoke all on public.company_identities from anon, authenticated; +revoke all on public.company_profiles from anon, authenticated; +revoke all on public.company_diffs from anon, authenticated; + +grant select, insert, update on public.company_identities to service_role; +grant select, insert, update on public.company_profiles to service_role; +grant select, insert, update on public.company_diffs to service_role; +``` + +No browser/client code receives the service-role key. + +- [ ] **Step 4: Add the read-only lookup RPC** + +Implement `public.lookup_company_identities(p_tax_id text, p_domain text, +p_name text)` as `LANGUAGE sql STABLE SECURITY INVOKER SET search_path = ''`. +Return distinct identity rows matching any non-null supplied key. Fully qualify +`public.company_identities` and order by `id` for deterministic adapter output. + +Revoke default execute and grant only server execution: + +```sql +revoke execute on function public.lookup_company_identities(text, text, text) + from public, anon, authenticated; +grant execute on function public.lookup_company_identities(text, text, text) + to service_role; +``` + +- [ ] **Step 5: Add the transactional resolve/create RPC** + +Implement `public.resolve_company_identity` with these exact branches: + +```plpgsql +if p_tax_id is not null then + insert into public.company_identities ( + id, tax_id, normalized_domain, normalized_name + ) values ( + p_candidate_id, p_tax_id, p_domain, p_name + ) on conflict (tax_id) where tax_id is not null do nothing; + + select id into resolved_id + from public.company_identities + where tax_id = p_tax_id; +elsif p_domain is not null then + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_domain)); + + select id into resolved_id + from public.company_identities + where normalized_domain = p_domain + and normalized_name = p_name + order by id + limit 1; + + if resolved_id is null then + insert into public.company_identities ( + id, normalized_domain, normalized_name + ) values ( + p_candidate_id, p_domain, p_name + ) returning id into resolved_id; + end if; +else + insert into public.company_identities (id, normalized_name) + values (p_candidate_id, p_name) + returning id into resolved_id; +end if; +``` + +Before returning, detect supplied tax/domain disagreement and raise a stable +exception marker `identity_conflict`. Do not update an existing identity's keys +inside the tax-ID conflict branch. + +Declare the function `SECURITY INVOKER SET search_path = ''`, revoke execution +from `public`, `anon`, and `authenticated`, and grant it to `service_role`. + +- [ ] **Step 6: Add atomic snapshot persistence RPC** + +Implement `public.persist_research_snapshot` as one PL/pgSQL transaction that: + +1. Locks the target `company_identities` row with `FOR UPDATE`. +2. Rechecks the pipeline-derived tax ID and domain against other identities. +3. Raises `identity_conflict` before mutation when either key belongs to a + different identity under the approved conflict rules. +4. Updates only the target identity's non-conflicting normalized metadata. +5. Upserts `company_profiles(id, version, official_name, data, + analysis_report, updated_at)` on `(id, version)`. +6. Upserts the supplied diff when non-null; version 1 accepts null. +7. Returns the authoritative `updated_at` value. + +Use `SECURITY INVOKER SET search_path = ''`, explicit schema qualification, +server-only execute grants, and no dynamic SQL. + +- [ ] **Step 7: Apply and inspect the local schema** + +Run: + +```bash +npx supabase start +npx supabase db reset +npx supabase migration list --local +npx supabase db advisors --local +``` + +Expected: migration is applied, no duplicate/failed migration, and advisors +report no security/performance issue introduced by these objects. + +- [ ] **Step 8: Commit schema and RPCs** + +```bash +git add supabase/schema.sql supabase/migrations +git commit -m "feat(db): add research cache schema" +``` + +### Task 3: Deepen the storage interface and memory adapter + +**Files:** + +- Modify: `src/adapters/storage/types.ts` +- Modify: `src/adapters/storage/memory.ts` +- Modify: `tests/unit/adapters.test.ts` + +**Interfaces:** + +Add these methods while retaining existing profile/diff methods until Sprint 03 +migrates the workflow: + +```ts +findIdentityCandidates( + identity: NormalizedCompanyIdentity, + options?: StorageReadOptions, +): Promise; + +getLatestCompleteSnapshot( + companyId: string, + options?: StorageReadOptions, +): Promise; + +resolveOrCreateIdentity( + identity: NormalizedCompanyIdentity, + candidateId: string, + options?: StorageWriteOptions, +): Promise; + +persistResearchSnapshot( + identity: NormalizedCompanyIdentity, + snapshot: Omit, + options?: StorageWriteOptions, +): Promise; +``` + +- [ ] **Step 1: Write failing memory-adapter tests** + +Add tests proving: + +```ts +await storage.resolveOrCreateIdentity(identity, "company-a"); +await storage.persistResearchSnapshot(identity, draft); + +await expect(storage.findIdentityCandidates(identity)).resolves.toEqual([ + expect.objectContaining({ companyId: "company-a" }), +]); +await expect(storage.getLatestCompleteSnapshot("company-a")).resolves + .toMatchObject({ profile: { id: "company-a" }, report: { companyId: "company-a" } }); +``` + +Add an invalid selection/conflicting tax-ID case and a version-2 snapshot whose +diff matches `toVersion: 2`. + +- [ ] **Step 2: Run and verify interface failures** + +Run: `npm test -- tests/unit/adapters.test.ts` + +Expected: FAIL because the new methods do not exist. + +- [ ] **Step 3: Implement the minimum in-memory parity** + +Store identities in a `Map` and complete snapshots +in the existing company/version maps. Reuse Sprint 01's `decideCacheLookup` +rules; do not create a second normalization implementation. Return cloned +arrays/objects where mutation would leak across tests. + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: + +```bash +npm test -- tests/unit/adapters.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the storage seam** + +```bash +git add src/adapters/storage/types.ts src/adapters/storage/memory.ts tests/unit/adapters.test.ts +git commit -m "feat(storage): add complete cache snapshots" +``` + +### Task 4: Implement Supabase storage and server-only credentials + +**Files:** + +- Modify: `.env.example` +- Modify: `src/config/index.ts` +- Modify: `src/adapters/storage/supabase.ts` +- Modify: `tests/unit/supabase-storage.test.ts` + +**Interfaces:** Implements all Sprint 02 `StorageAdapter` methods via +`.rpc(...)`, complete-row selection, and exact-version diff selection. + +- [ ] **Step 1: Write failing service-key and RPC mapping tests** + +Mock the Supabase client boundary and assert: + +- Supabase storage refuses startup without `SUPABASE_SERVICE_ROLE_KEY`. +- Identity lookup calls `lookup_company_identities` with normalized values. +- Identity creation calls `resolve_company_identity`. +- Snapshot persistence calls `persist_research_snapshot`. +- Complete snapshot selection filters `analysis_report` non-null, orders version + descending, then fetches diff by exact `company_id` and `to_version`. +- Abort signals propagate to all PostgREST/RPC builders that support them. + +- [ ] **Step 2: Run and verify tests fail** + +Run: `npm test -- tests/unit/supabase-storage.test.ts` + +Expected: FAIL against the old adapter constructor/method set. + +- [ ] **Step 3: Require the server-only credential** + +Document in `.env.example`: + +```dotenv +SUPABASE_URL=https://xyz.supabase.co +SUPABASE_SERVICE_ROLE_KEY=server-only-secret +``` + +Remove the Supabase storage path's use of `SUPABASE_ANON_KEY` in +`createStorageAdapter`. The adapter constructor accepts URL and service-role key +only. Do not rename the key with a `NEXT_PUBLIC_` prefix. + +- [ ] **Step 4: Implement RPC and snapshot mapping** + +Call the three exact RPC names from Task 2. Parse every JSONB response through +`ResearchSnapshotSchema`; do not use `as CompanyProfile` or +`as AnalysisReport`. Convert known RPC conflict markers to a typed +`IdentityConflictError` exported from `src/modules/cache/index.ts`; convert +transport errors to a storage error without provider fallback. + +- [ ] **Step 5: Run focused tests** + +Run: + +```bash +npm test -- tests/unit/supabase-storage.test.ts tests/unit/adapters.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Supabase adapter behavior** + +```bash +git add .env.example src/config/index.ts src/adapters/storage/supabase.ts tests/unit/supabase-storage.test.ts +git commit -m "feat(storage): use transactional supabase cache" +``` + +### Task 5: Prove transaction-level concurrency with two clients + +**Files:** + +- Create: `tests/integration/supabase-cache-concurrency.test.ts` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** Uses `SUPABASE_TEST_URL` and +`SUPABASE_TEST_SERVICE_ROLE_KEY`; never uses production credentials. + +- [ ] **Step 1: Write the real concurrent RPC test** + +Create two independent `createClient` instances, start both RPC calls before +awaiting either, and assert one identity: + +```ts +const first = createClient(testUrl, serviceKey, clientOptions); +const second = createClient(testUrl, serviceKey, clientOptions); +const domain = `race-${crypto.randomUUID()}.example`; +const name = `race ${crypto.randomUUID()}`; + +const [a, b] = await Promise.all([ + first.rpc("resolve_company_identity", { + p_tax_id: null, + p_domain: domain, + p_name: name, + p_candidate_id: crypto.randomUUID(), + }), + second.rpc("resolve_company_identity", { + p_tax_id: null, + p_domain: domain, + p_name: name, + p_candidate_id: crypto.randomUUID(), + }), +]); + +expect(a.error).toBeNull(); +expect(b.error).toBeNull(); +expect(a.data).toBe(b.data); +``` + +Query `company_identities` with the service-role test client and assert exactly +one row for that domain/name. Add a rollback test for post-pipeline conflict so +no profile/diff row survives a failed persistence RPC. + +- [ ] **Step 2: Run against local Supabase** + +Run: + +```bash +eval "$(npx supabase status -o env)" +SUPABASE_TEST_URL="$API_URL" \ +SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \ +npm run test:db +``` + +Expected: both concurrent calls return the same ID and all database integration +tests pass. + +- [ ] **Step 3: Add the same local database gate to CI** + +After `npm ci`, add CI steps that run `npx supabase start`, export local API URL +and service key only within the database-test step, run `npm run test:db`, then +stop the stack with `npx supabase stop --no-backup`. Do not print service keys. + +- [ ] **Step 4: Commit concurrency verification** + +```bash +git add tests/integration/supabase-cache-concurrency.test.ts .github/workflows/ci.yml +git commit -m "test(db): verify cache identity locking" +``` + +## Sprint 02 review gate + +Run: + +```bash +npx supabase db reset +npx supabase migration list --local +npx supabase db advisors --local +eval "$(npx supabase status -o env)" +SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" npm run test:db +npm test -- tests/unit/adapters.test.ts tests/unit/supabase-storage.test.ts +npm run lint +npm run typecheck +git status --short +``` + +Expected: migration/advisors pass, independent-client concurrency passes, unit +tests pass, and the worktree is clean before Sprint 03. diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-03-server-read-through-flow.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-03-server-read-through-flow.md new file mode 100644 index 0000000..3a21e2b --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-03-server-read-through-flow.md @@ -0,0 +1,494 @@ +# Sprint 03 — Server Read-Through Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put the complete Supabase snapshot in front of the expensive research +pipeline, bind every client selection to current input, and persist misses or +refreshes atomically under a canonical company ID. + +**Architecture:** Deepen the pure cache module into a storage-backed research +cache, then keep the route as the orchestration point. The workflow becomes +storage-independent: it receives canonical identity/previous profile, produces +a terminal state, and leaves final SSE emission/persistence to the route. + +**Tech Stack:** Next.js 16.3.2 Node Route Handler, Web Streams/SSE, LangGraph +1.4.12, Zod 4.4.3, Supabase storage seam, Vitest 4.1.11. + +**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md` + +## Global Constraints + +- Read the installed Next.js 16 route-handler and streaming guides before + editing `route.ts`. +- Construct only storage/cache dependencies before cache resolution. +- Paid provider factories must remain untouched on hit, invalid selection, + identity conflict, and cache-backend failure. +- Recompute candidates server-side for every `select` and `refresh` request. +- Never derive canonical identity with `slugify(name)`. +- Workflow owns research computation; route owns persistence and final SSE + events. +- Every started SSE stream ends with `done`. +- A corrupt cache is recoverable; a persist failure or post-pipeline identity + conflict is fatal. +- Do not add a queue, background job, rate limiter, TTL, or LRU. +- Stage only files named by each task. + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `src/modules/cache/index.ts` | Modify | Storage-backed lookup, selection, refresh binding, and persistence interface | +| `src/modules/workflow/state.ts` | Modify | Canonical ID and supplied previous profile in initial state | +| `src/modules/workflow/index.ts` | Modify | Remove storage reads/writes and final result emission | +| `src/app/api/research/route.ts` | Modify | Cache-first orchestration, HTTP/SSE errors, lazy providers, atomic persist | +| `src/lib/stream.ts` | Modify only if required | Preserve explicit error/done ordering and close-once behavior | +| `tests/unit/research-cache.test.ts` | Modify | Storage-backed selection/refresh/cache-invalid behavior | +| `tests/integration/research-workflow.test.ts` | Modify | Storage-independent canonical workflow behavior | +| `tests/unit/research-cache-route.test.ts` | Create | Hit/miss/selection/conflict/provider-construction route coverage | +| `tests/unit/research-route-observability.test.ts` | Modify | New request body and route terminal behavior | +| `tests/e2e/workflow-e2e.test.ts` | Modify | New request body and final SSE ownership | + +### Task 1: Build the storage-backed research cache module + +**Files:** + +- Modify: `src/modules/cache/index.ts` +- Modify: `tests/unit/research-cache.test.ts` + +**Interfaces:** + +```ts +export type CacheResolution = + | { + kind: "hit"; + snapshot: ResearchSnapshot; + matchedBy: "tax_id" | "domain"; + } + | { kind: "suggestions"; suggestions: CacheSuggestion[] } + | { + kind: "miss"; + identity: NormalizedCompanyIdentity; + cacheInvalid: boolean; + } + | { + kind: "conflict"; + taxCompanyId: string; + domainCompanyIds: string[]; + }; + +export interface ResearchCache { + lookup(input: CompanyInput, options?: StorageReadOptions): Promise; + select( + input: CompanyInput, + companyId: string, + options?: StorageReadOptions, + ): Promise; + prepareRefresh( + input: CompanyInput, + companyId: string, + options?: StorageReadOptions, + ): Promise; + resolveMiss( + input: CompanyInput, + options?: StorageWriteOptions, + ): Promise<{ companyId: string; identity: NormalizedCompanyIdentity }>; + persist( + identity: NormalizedCompanyIdentity, + snapshot: Omit, + options?: StorageWriteOptions, + ): Promise; +} + +export function createResearchCache(storage: StorageAdapter): ResearchCache; +``` + +- [ ] **Step 1: Write failing hit/suggestion/miss tests** + +Use `MemoryStorageAdapter` and prove: + +```ts +await expect(cache.lookup({ name: "FPT", taxId: "0101248141" })) + .resolves.toMatchObject({ + kind: "hit", + matchedBy: "tax_id", + snapshot: { profile: { id: "company-a" } }, + }); + +await expect(cache.lookup({ name: "FPT" })).resolves.toMatchObject({ + kind: "suggestions", + suggestions: [expect.objectContaining({ companyId: "company-a" })], +}); + +await expect(cache.lookup({ name: "Unknown" })).resolves.toEqual({ + kind: "miss", + identity: { taxId: null, domain: null, name: "unknown" }, + cacheInvalid: false, +}); +``` + +- [ ] **Step 2: Write the real unrelated-existing-ID tests** + +Seed complete snapshots for company A and company B. Assert: + +```ts +await expect(cache.select({ name: "Company A" }, "company-b")) + .rejects.toMatchObject({ code: "invalid_cache_selection" }); + +await expect(cache.prepareRefresh( + { name: "Company A", taxId: "tax-a" }, + "company-b", +)).rejects.toMatchObject({ code: "identity_conflict" }); +``` + +The rejected IDs must exist and have valid snapshots; a nonexistent ID does not +cover the input-binding vulnerability. + +- [ ] **Step 3: Write a corrupt-snapshot recovery test** + +Make `getLatestCompleteSnapshot` throw `CacheInvalidError` and assert `lookup` +returns the same normalized miss with `cacheInvalid: true`. Transport/storage +errors must propagate instead of becoming a miss. + +- [ ] **Step 4: Run and verify missing module behavior fails** + +Run: `npm test -- tests/unit/research-cache.test.ts` + +Expected: FAIL because `createResearchCache` and the typed cache errors are not +implemented. + +- [ ] **Step 5: Implement the minimum cache orchestration** + +`lookup` calls `findIdentityCandidates`, passes the result to +`decideCacheLookup`, then loads complete snapshots only for the chosen hit or +suggestion IDs. Drop identities without a complete snapshot from suggestions. +If no complete candidate remains, return a miss and retain the normalized +identity for `resolveMiss`. + +`select` reruns the full lookup and accepts `companyId` only when it appears in +the current suggestion set. `prepareRefresh` reruns lookup and accepts the ID +only when the hit/suggestion set contains it without a strong-key conflict. + +`resolveMiss` calls: + +```ts +storage.resolveOrCreateIdentity( + normalizeCompanyIdentity(input), + crypto.randomUUID(), + options, +); +``` + +`persist` delegates once to `persistResearchSnapshot` and returns the database +timestamped snapshot. + +- [ ] **Step 6: Run focused cache tests** + +Run: + +```bash +npm test -- tests/unit/research-cache.test.ts tests/unit/adapters.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the cache module** + +```bash +git add src/modules/cache/index.ts tests/unit/research-cache.test.ts +git commit -m "feat(cache): bind cache decisions to input" +``` + +### Task 2: Make the workflow canonical-ID driven and storage independent + +**Files:** + +- Modify: `src/modules/workflow/state.ts` +- Modify: `src/modules/workflow/index.ts` +- Modify: `tests/integration/research-workflow.test.ts` + +**Interfaces:** + +Change workflow options/state initialization to require: + +```ts +export interface ResearchWorkflowOptions { + researchRunId: string; + companyId: string; + existingProfile: CompanyProfile | null; + signal?: AbortSignal; + callbacks?: readonly unknown[]; + sessionId?: string; + onComplete?: (state: ResearchWorkflowState) => void | Promise; +} +``` + +Remove `storage` from `ResearchWorkflowDeps` after all workflow reads/writes are +deleted. + +- [ ] **Step 1: Rewrite failing workflow tests around supplied identity** + +Replace storage-failure tests with these assertions: + +```ts +const state = await workflow.run( + { name: "Different Display Name" }, + { + researchRunId: "canonical-id", + companyId: "stable-company-id", + existingProfile, + }, +); + +expect(state.profile?.id).toBe("stable-company-id"); +expect(state.profile?.version).toBe(existingProfile.version + 1); +expect(state.diff).toMatchObject({ + companyId: "stable-company-id", + fromVersion: existingProfile.version, + toVersion: existingProfile.version + 1, +}); +``` + +Add a stream test proving source/progress/build events still appear but +`profile:ready`, `diff:ready`, `analysis:ready`, and `done` do not; the route +will own those final events. + +- [ ] **Step 2: Run and verify old workflow behavior fails the new assertions** + +Run: `npm test -- tests/integration/research-workflow.test.ts` + +Expected: FAIL because options do not accept canonical identity and the graph +still reads/writes storage/emits final events. + +- [ ] **Step 3: Remove storage nodes and derive state from options** + +Initialize state with: + +```ts +{ + researchRunId: options.researchRunId, + input, + existingProfile: options.existingProfile, + // existing source/findings/profile/diff/report/outcome defaults remain +} +``` + +Build the profile with `options.companyId`; calculate diff without saving it. +Remove `load_existing_profile` and `persist_profile` nodes/edges. Remove final +result custom events from diff/analyze nodes and remove the workflow-level +`done` yield. Keep fatal/source error events and always invoke `onComplete` with +the terminal state. + +- [ ] **Step 4: Delete storage from workflow callers/tests** + +Remove `storage` from `ResearchWorkflowDeps`, workflow construction, and test +builders. Keep storage tests in the adapter/cache suites. + +- [ ] **Step 5: Run workflow tests and typecheck** + +Run: + +```bash +npm test -- tests/integration/research-workflow.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit workflow isolation** + +```bash +git add src/modules/workflow/state.ts src/modules/workflow/index.ts tests/integration/research-workflow.test.ts +git commit -m "refactor(workflow): accept canonical cache state" +``` + +### Task 3: Put cache lookup before all paid providers + +**Files:** + +- Modify: `src/app/api/research/route.ts` +- Create: `tests/unit/research-cache-route.test.ts` +- Modify: `tests/unit/research-route-observability.test.ts` + +**Interfaces:** + +- HTTP errors use `{ error: { code, message } }` and the status table from spec + section 12. +- Started SSE streams use `cache:*`, current progress events, explicit error + codes, final result events, and `done`. + +- [ ] **Step 1: Write a cache-hit route test that forbids provider construction** + +Mock `createStorageAdapter` with a complete cached snapshot and define +`createLLMAdapter`, `createSearchAdapter`, and `createScraperAdapter` as spies +that throw if called. POST: + +```json +{ "input": { "name": "FPT", "taxId": "0101248141" } } +``` + +Assert status 200 and this event order: + +```ts +expect(body.indexOf("event: cache:hit")).toBeLessThan(body.indexOf("event: profile:ready")); +expect(body.indexOf("event: profile:ready")).toBeLessThan(body.indexOf("event: diff:ready")); +expect(body.indexOf("event: diff:ready")).toBeLessThan(body.indexOf("event: analysis:ready")); +expect(body.trimEnd()).toContain("event: done"); +expect(createLLMAdapter).not.toHaveBeenCalled(); +expect(createSearchAdapter).not.toHaveBeenCalled(); +expect(createScraperAdapter).not.toHaveBeenCalled(); +``` + +- [ ] **Step 2: Write pre-stream failure tests** + +Cover: + +- Existing unrelated `companyId` on `select` → `400 invalid_cache_selection`. +- Strong-key disagreement → `409 identity_conflict`. +- Storage/RPC transport failure → `503 cache_unavailable`. + +For every case, assert paid provider factories have zero calls. + +- [ ] **Step 3: Write miss and corrupt-cache tests** + +- Empty lookup: assert identity resolves and workflow/provider factories are + constructed without a second bypass request. +- Corrupt snapshot: assert SSE contains `error.code = cache_invalid`, then + progress/persisted final events and `done`. + +- [ ] **Step 4: Run route tests and verify they fail** + +Run: `npm test -- tests/unit/research-cache-route.test.ts` + +Expected: FAIL against the current eager-provider route. + +- [ ] **Step 5: Implement cache-first route branching** + +Parse `ResearchRequestSchema`, create storage/cache, and resolve the cache before +calling any paid-provider factory. Use these branches: + +```ts +switch (resolution.kind) { + case "hit": + return streamCachedSnapshot(resolution); + case "suggestions": + return streamSuggestions(resolution.suggestions); + case "conflict": + return jsonError(409, "identity_conflict", "Thông tin định danh công ty mâu thuẫn."); + case "miss": + break; +} +``` + +Handle `select`, `refresh`, and `bypass` before default lookup. `bypass` skips +returning suggestions but still calls `resolveMiss`; it never accepts a client +company ID. + +Only after identity resolution succeeds, construct providers/workflow and start +the long-running SSE flow. + +- [ ] **Step 6: Centralize final events after atomic persistence** + +Capture workflow terminal state through `onComplete`. If it has profile/report +and no fatal error, call `cache.persist` once. On success write: + +```ts +writer.write({ event: "profile:ready", data: { profile: persisted.profile } }); +writer.write({ event: "diff:ready", data: { diff: persisted.diff } }); +writer.write({ event: "analysis:ready", data: { report: persisted.report } }); +writer.write({ event: "done", data: {} }); +``` + +On persistence conflict write `error(code: identity_conflict)` then `done`; on +other persistence errors write `error(code: persist_failed)` then `done`. Do not +write profile/diff/analysis first. Preserve abort/deadline cleanup and close the +writer exactly once in `finally`. + +- [ ] **Step 7: Run route tests and typecheck** + +Run: + +```bash +npm test -- tests/unit/research-cache-route.test.ts tests/unit/research-route-observability.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 8: Commit route orchestration** + +```bash +git add src/app/api/research/route.ts tests/unit/research-cache-route.test.ts tests/unit/research-route-observability.test.ts +git commit -m "feat(api): serve research through cache" +``` + +### Task 4: Preserve end-to-end SSE behavior + +**Files:** + +- Modify: `tests/e2e/workflow-e2e.test.ts` +- Modify only if tests require it: `src/lib/stream.ts` + +**Interfaces:** Existing SSE serialization plus new explicit cache/error events. + +- [ ] **Step 1: Update E2E requests and assertions** + +Send nested `{ input }` request bodies. Add one seeded cache hit and one miss. +Assert both terminate with exactly one `done`; hit has no source progress and +miss retains research progress before final events. + +- [ ] **Step 2: Add fatal terminal-path assertions** + +For post-pipeline conflict and persistence failure, assert: + +```ts +expect(events.at(-2)).toMatchObject({ event: "error" }); +expect(events.at(-1)).toEqual({ event: "done", data: {} }); +expect(events.some(({ event }) => event === "profile:ready")).toBe(false); +``` + +- [ ] **Step 3: Run E2E and full focused server suites** + +Run: + +```bash +npm test -- tests/e2e/workflow-e2e.test.ts \ + tests/unit/research-cache-route.test.ts \ + tests/integration/research-workflow.test.ts +``` + +Expected: PASS. Change `src/lib/stream.ts` only if close-once or event-order +behavior cannot be expressed with its current interface. + +- [ ] **Step 4: Commit E2E contract** + +```bash +git add tests/e2e/workflow-e2e.test.ts +git diff -- src/lib/stream.ts +git commit -m "test(api): cover cache sse outcomes" +``` + +If `src/lib/stream.ts` changed, include it in `git add`; otherwise leave it +untouched. + +## Sprint 03 review gate + +Run: + +```bash +npm test -- tests/unit/research-cache.test.ts \ + tests/unit/research-cache-route.test.ts \ + tests/unit/research-route-observability.test.ts \ + tests/integration/research-workflow.test.ts \ + tests/e2e/workflow-e2e.test.ts +npm run lint +npm run typecheck +npm run build +git status --short +``` + +Expected: cache hit never touches paid factories; all HTTP/SSE failure paths are +terminal; miss/refresh persist only complete snapshots; worktree is clean before +Sprint 04. diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-04-client-suggestions-and-refresh.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-04-client-suggestions-and-refresh.md new file mode 100644 index 0000000..aa7456c --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-04-client-suggestions-and-refresh.md @@ -0,0 +1,460 @@ +# Sprint 04 — Client Suggestions and Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users safely confirm name/domain cache suggestions, see cache +freshness, reject incorrect suggestions, and explicitly refresh a cached +company. + +**Architecture:** Keep network and SSE state in the existing `useResearch` +client hook. Add one inline suggestion component and a pure exported reducer so +state transitions are testable under the existing Node Vitest environment +without installing a DOM test framework. + +**Tech Stack:** React 19.2.8 Client Components, Next.js 16.3.2 App Router, +TypeScript, existing Tailwind CSS styles, Vitest 4.1.11. + +**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md` + +## Global Constraints + +- Do not use localStorage, browser cache APIs, or client-side Supabase. +- Never decide whether a selected company is valid in the browser; the server + remains authoritative. +- Use one inline accessible panel, not a modal dependency. +- Display official name, available tax ID/domain, and last synchronized time. +- Make “Không phải các công ty trên” and “Cập nhật lại” explicit actions. +- A recoverable `cache_invalid` is a notice while fresh research continues; it + is not a terminal error. +- Fatal errors and every `done` event must leave the UI out of loading state. +- Preserve existing research progress and profile rendering. +- Do not redesign unrelated page/header/form/profile styles. +- Stage only files named by each task. + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `src/app/hooks/use-research.ts` | Modify | Cache-aware request builder, reducer, actions, and SSE state | +| `src/app/components/cache-suggestions.tsx` | Create | Accessible inline candidate selection | +| `src/app/page.tsx` | Modify | Render suggestions, freshness, bypass, and refresh controls | +| `tests/unit/use-research-state.test.ts` | Create | Pure request/reducer state-transition tests | + +### Task 1: Make cache request and SSE transitions pure and testable + +**Files:** + +- Modify: `src/app/hooks/use-research.ts` +- Create: `tests/unit/use-research-state.test.ts` + +**Interfaces:** + +```ts +export interface CacheState { + hit: boolean; + matchedBy: CacheHitMatchedBy | null; + lastSyncedAt: string | null; + suggestions: CacheSuggestion[]; +} + +export interface ResearchState { + status: "idle" | "researching" | "choosing" | "building" | "done" | "error"; + // existing fields remain + cache: CacheState; + notice: string | null; +} + +export function buildResearchRequest( + input: CompanyInput, + cache?: ResearchRequest["cache"], +): ResearchRequest; + +export function reduceResearchEvent( + state: ResearchState, + event: StreamEvent, +): ResearchState; +``` + +- [ ] **Step 1: Write failing request-builder tests** + +```ts +it("builds default, selected, bypass, and refresh requests", () => { + const input = { name: "FPT" }; + + expect(buildResearchRequest(input)).toEqual({ input }); + expect(buildResearchRequest(input, { action: "select", companyId: "fpt" })) + .toEqual({ input, cache: { action: "select", companyId: "fpt" } }); + expect(buildResearchRequest(input, { action: "bypass" })) + .toEqual({ input, cache: { action: "bypass" } }); + expect(buildResearchRequest(input, { action: "refresh", companyId: "fpt" })) + .toEqual({ input, cache: { action: "refresh", companyId: "fpt" } }); +}); +``` + +- [ ] **Step 2: Write failing cache-event reducer tests** + +```ts +it("enters choosing state when suggestions arrive and stays there on done", () => { + const suggested = reduceResearchEvent(researchingState, { + event: "cache:suggestions", + data: { suggestions: [suggestion] }, + }); + const finished = reduceResearchEvent(suggested, { event: "done", data: {} }); + + expect(finished.status).toBe("choosing"); + expect(finished.cache.suggestions).toEqual([suggestion]); +}); + +it("records cache metadata before applying cached final results", () => { + const next = reduceResearchEvent(researchingState, { + event: "cache:hit", + data: { + companyId: "fpt", + matchedBy: "tax_id", + version: 2, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }, + }); + + expect(next.cache).toMatchObject({ + hit: true, + matchedBy: "tax_id", + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }); +}); + +it("treats cache_invalid as recoverable and persist_failed as terminal", () => { + const recoverable = reduceResearchEvent(researchingState, { + event: "error", + data: { code: "cache_invalid", message: "Cache không hợp lệ" }, + }); + expect(recoverable.status).toBe("researching"); + expect(recoverable.notice).toBe("Cache không hợp lệ"); + + const fatal = reduceResearchEvent(researchingState, { + event: "error", + data: { code: "persist_failed", message: "Không thể lưu kết quả" }, + }); + expect(fatal.status).toBe("error"); +}); +``` + +- [ ] **Step 3: Run and verify exports are missing** + +Run: `npm test -- tests/unit/use-research-state.test.ts` + +Expected: FAIL because the request builder/reducer/cache state do not exist. + +- [ ] **Step 4: Implement the pure builder and reducer** + +Move the current `handleSSEEvent` switch into `reduceResearchEvent`. Keep all +existing progress/finding/profile/diff/report behavior and add: + +- `cache:hit` → set cache metadata. +- `cache:suggestions` → clear result fields, store suggestions, set `choosing`. +- `error(cache_invalid)` → set `notice`, keep active research state. +- Other `error` codes → set terminal error status/message. +- `done` with suggestions → `choosing`. +- `done` with profile → `done`. +- Other `done` with fatal error → `error`. + +`handleSSEEvent` becomes one `setState(prev => reduceResearchEvent(prev, +event))` call. Keep malformed JSON handling unchanged. + +- [ ] **Step 5: Run focused state tests and typecheck** + +Run: + +```bash +npm test -- tests/unit/use-research-state.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit state behavior** + +```bash +git add src/app/hooks/use-research.ts tests/unit/use-research-state.test.ts +git commit -m "feat(ui): handle cache research states" +``` + +### Task 2: Expose safe select, bypass, and refresh hook actions + +**Files:** + +- Modify: `src/app/hooks/use-research.ts` +- Modify: `tests/unit/use-research-state.test.ts` + +**Interfaces:** + +```ts +export interface UseResearchResult { + state: ResearchState; + research(input: CompanyInput): Promise; + selectSuggestion(companyId: string): Promise; + researchNewCompany(): Promise; + refresh(): Promise; + reset(): void; +} +``` + +- [ ] **Step 1: Add request-action tests** + +Test the action-to-request mapping through `buildResearchRequest`: + +- `selectSuggestion("company-a")` uses current `state.input` and action + `select`. +- `researchNewCompany()` uses current input and action `bypass`. +- `refresh()` uses current input/profile ID and action `refresh`. +- Missing input/profile produces a resolved no-op and no fetch. + +Do not install a hook rendering library. Keep network execution in one internal +`runResearch(input, cacheAction?)` callback and cover the pure request builder. + +- [ ] **Step 2: Implement one shared network path** + +Refactor current `research` so it delegates to: + +```ts +const runResearch = useCallback(async ( + input: CompanyInput, + cache?: ResearchRequest["cache"], +) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setState({ + ...INITIAL_STATE, + input, + status: "researching", + }); + + try { + const response = await fetch("/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildResearchRequest(input, cache)), + signal: controller.signal, + }); + + await consumeResearchStream(response, setState); + } catch (error) { + if ((error as Error).name === "AbortError") return; + setState((current) => ({ + ...current, + status: "error", + error: (error as Error).message, + })); + } +}, []); +``` + +Extract the current response-status check, reader loop, and SSE parsing into +`consumeResearchStream`; replace its state mutations with +`setState((current) => reduceResearchEvent(current, event))`. This is a move of +existing behavior, not a second network implementation. + +The public actions read current state only to construct the approved action and +then call `runResearch`. Clear previous suggestions when select, bypass, or +refresh starts. Keep abort behavior for repeated clicks. + +- [ ] **Step 3: Run state tests and typecheck** + +Run: + +```bash +npm test -- tests/unit/use-research-state.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 4: Commit hook actions** + +```bash +git add src/app/hooks/use-research.ts tests/unit/use-research-state.test.ts +git commit -m "feat(ui): add cache selection actions" +``` + +### Task 3: Render accessible cache suggestions + +**Files:** + +- Create: `src/app/components/cache-suggestions.tsx` +- Modify: `src/app/page.tsx` + +**Interfaces:** + +```ts +interface CacheSuggestionsProps { + suggestions: CacheSuggestion[]; + disabled: boolean; + onSelect(companyId: string): void; + onReject(): void; +} +``` + +- [ ] **Step 1: Create the inline semantic panel** + +Use this structure without a modal or new dependency: + +```tsx +
+
+

+ Chọn đúng doanh nghiệp +

+

+ Chúng tôi tìm thấy dữ liệu đã lưu có tên tương tự. +

+
+
    + {suggestions.map((suggestion) => ( +
  • + +
  • + ))} +
+ +
+``` + +Reuse existing focus styles or add visible `focus-visible` utilities to both +button types. Do not remove native button semantics. + +- [ ] **Step 2: Wire choosing state into the page** + +Destructure `selectSuggestion` and `researchNewCompany` from the hook. When +`state.status === "choosing"`, render `CacheSuggestions` in the result area +instead of research progress/profile. Preserve the left-side form and current +input so the user can correct identifiers instead. + +- [ ] **Step 3: Run lint/typecheck/build** + +Run: + +```bash +npm run lint +npm run typecheck +npm run build +``` + +Expected: PASS with no client/server boundary violation. + +- [ ] **Step 4: Commit suggestions UI** + +```bash +git add src/app/components/cache-suggestions.tsx src/app/page.tsx +git commit -m "feat(ui): confirm cached companies" +``` + +### Task 4: Display cache freshness and manual refresh + +**Files:** + +- Modify: `src/app/page.tsx` +- Modify: `tests/unit/use-research-state.test.ts` + +**Interfaces:** Uses `state.cache.lastSyncedAt`, `state.profile.id`, and the +hook's `refresh()` action. + +- [ ] **Step 1: Add refresh state expectations** + +Extend reducer/request tests to prove refresh: + +- clears previous suggestions/notices; +- sets status to `researching`; +- sends the displayed profile ID; +- a subsequent result replaces cache metadata and profile version. + +- [ ] **Step 2: Add the freshness/refresh controls above the profile** + +When a profile and `lastSyncedAt` exist, render: + +```tsx +
+ + Cập nhật lần cuối: {new Date(state.cache.lastSyncedAt).toLocaleString("vi-VN")} + + +
+``` + +Show `state.notice` as a neutral/warning callout, distinct from the existing +fatal error callout. + +- [ ] **Step 3: Run focused and full UI compilation checks** + +Run: + +```bash +npm test -- tests/unit/use-research-state.test.ts +npm run lint +npm run typecheck +npm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Verify visually** + +Run `npm run dev`, then use the in-app browser workflow to capture before/after +screenshots for: + +1. Multiple cache suggestions. +2. Cached profile with last-synchronized time. +3. Refresh in progress with disabled button. +4. Recoverable cache-invalid notice. + +Check keyboard focus order, readable timestamps, narrow viewport wrapping, and +that selecting/rejecting suggestions reaches the intended server request. + +- [ ] **Step 5: Commit freshness UI** + +```bash +git add src/app/page.tsx tests/unit/use-research-state.test.ts +git commit -m "feat(ui): show cache freshness and refresh" +``` + +## Sprint 04 review gate + +Run: + +```bash +npm test -- tests/unit/use-research-state.test.ts \ + tests/unit/research-cache-route.test.ts \ + tests/e2e/workflow-e2e.test.ts +npm run lint +npm run typecheck +npm run build +git status --short +``` + +Expected: state/action/server contract tests pass, all four UI states are +visually verified, accessibility basics work by keyboard, and the worktree is +clean before Sprint 05. diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-05-telemetry-and-release-hardening.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-05-telemetry-and-release-hardening.md new file mode 100644 index 0000000..83aba48 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-05-telemetry-and-release-hardening.md @@ -0,0 +1,402 @@ +# Sprint 05 — Telemetry and Release Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make cache outcomes observable without leaking identifiers, close the +remaining negative-path coverage, verify database permissions/concurrency, and +prepare the complete feature for release. + +**Architecture:** Extend the existing Langfuse wrapper with one cache-outcome +metadata function and one HMAC fingerprint helper. Keep route/storage behavior +unchanged except for telemetry calls; finish with security, database, UI, and +full repository verification. + +**Tech Stack:** Node.js `crypto`, Langfuse JS/TS 5.10.1, +`@langfuse/tracing` 5.10.1, Vitest 4.1.11, Supabase CLI 2.115.0, +Next.js 16.3.2. + +**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md` + +## Global Constraints + +- Use HMAC-SHA256 with a dedicated server secret; plain SHA-256 is forbidden + for low-entropy tax IDs. +- Never log raw tax IDs, domains, credentials, or request bodies. +- Missing HMAC secret omits the fingerprint and does not fail research. +- Reuse Langfuse; do not add a logger, metrics SDK, or telemetry dependency. +- Cache telemetry must also exist for hits/suggestions that never create paid + providers. +- Do not change cache lookup/persistence semantics while adding telemetry. +- Verify anon/authenticated cannot call cache RPCs or access cache tables. +- Refresh rate limiting remains explicitly deferred. +- Stage only files named by each task. + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `.env.example` | Modify | Dedicated HMAC secret documentation | +| `src/observability/langfuse.ts` | Modify | HMAC fingerprint and cache outcome metadata | +| `src/app/api/research/route.ts` | Modify | Record lookup outcomes across every route branch | +| `tests/unit/langfuse-observability.test.ts` | Modify | Fingerprint and cache metadata tests | +| `tests/unit/research-cache-route.test.ts` | Modify | Final negative-path and telemetry matrix | +| `tests/integration/supabase-cache-concurrency.test.ts` | Modify | Data API grants/RLS and rollback verification | +| `README.md` | Modify | Cache behavior, configuration, and operational caveats | +| `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md` | Read only | Acceptance checklist source | + +### Task 1: Add non-reversible cache-key fingerprints + +**Files:** + +- Modify: `.env.example` +- Modify: `src/observability/langfuse.ts` +- Modify: `tests/unit/langfuse-observability.test.ts` + +**Interfaces:** + +```ts +export function fingerprintCacheKey( + keyType: "tax_id" | "domain", + value: string, + secret?: string, +): string | undefined; + +export interface ResearchCacheTelemetry { + cacheOutcome: + | "hit" + | "miss" + | "suggestions" + | "refresh" + | "bypass" + | "conflict" + | "invalid"; + matchedBy?: "tax_id" | "domain" | "normalized_name" | "selected"; + companyId?: string; + version?: number; + lastSyncedAt?: string; + lookupDurationMs: number; + conflictingCompanyIds?: string[]; + keyType?: "tax_id" | "domain"; + keyFingerprint?: string; +} + +export function updateResearchCacheOutcome( + telemetry: ResearchCacheTelemetry, +): void; +``` + +- [ ] **Step 1: Write failing HMAC tests** + +```ts +it("fingerprints low-entropy tax IDs with a keyed HMAC", () => { + const first = fingerprintCacheKey("tax_id", "0101248141", "secret-a"); + const second = fingerprintCacheKey("tax_id", "0101248141", "secret-b"); + + expect(first).toMatch(/^[a-f0-9]{64}$/); + expect(first).not.toContain("0101248141"); + expect(second).not.toBe(first); + expect(fingerprintCacheKey("tax_id", "0101248141", undefined)).toBeUndefined(); +}); + +it("separates key types in the authenticated message", () => { + expect(fingerprintCacheKey("tax_id", "example.vn", "secret")) + .not.toBe(fingerprintCacheKey("domain", "example.vn", "secret")); +}); +``` + +- [ ] **Step 2: Run and verify the helper is missing** + +Run: `npm test -- tests/unit/langfuse-observability.test.ts` + +Expected: FAIL because `fingerprintCacheKey` is not exported. + +- [ ] **Step 3: Implement HMAC with Node's standard library** + +```ts +import { createHmac } from "node:crypto"; + +export function fingerprintCacheKey( + keyType: "tax_id" | "domain", + value: string, + secret = process.env.CACHE_TELEMETRY_HMAC_SECRET, +): string | undefined { + if (!secret) return undefined; + return createHmac("sha256", secret) + .update(`${keyType}\0${value}`) + .digest("hex"); +} +``` + +Document in `.env.example`: + +```dotenv +# Server-only HMAC secret for cache-key telemetry fingerprints +CACHE_TELEMETRY_HMAC_SECRET=replace-with-random-server-secret +``` + +Do not prefix it with `NEXT_PUBLIC_` and do not reuse a Supabase/Langfuse key. + +- [ ] **Step 4: Run focused tests and typecheck** + +Run: + +```bash +npm test -- tests/unit/langfuse-observability.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the fingerprint helper** + +```bash +git add .env.example src/observability/langfuse.ts tests/unit/langfuse-observability.test.ts +git commit -m "feat(observability): protect cache identifiers" +``` + +### Task 2: Record every cache outcome in the existing trace + +**Files:** + +- Modify: `src/observability/langfuse.ts` +- Modify: `src/app/api/research/route.ts` +- Modify: `tests/unit/langfuse-observability.test.ts` +- Modify: `tests/unit/research-cache-route.test.ts` + +**Interfaces:** `updateResearchCacheOutcome(telemetry)` updates active Langfuse +observation metadata and is a no-op when Langfuse is disabled. + +- [ ] **Step 1: Write failing metadata tests** + +Mock `updateActiveObservation` and assert: + +```ts +updateResearchCacheOutcome({ + cacheOutcome: "hit", + matchedBy: "tax_id", + companyId: "company-a", + version: 3, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + lookupDurationMs: 12, +}); + +expect(updateActiveObservation).toHaveBeenCalledWith({ + metadata: expect.objectContaining({ + cacheOutcome: "hit", + matchedBy: "tax_id", + companyId: "company-a", + cacheVersion: 3, + cacheLookupDurationMs: 12, + }), +}); +``` + +Add a conflict test proving metadata contains only company IDs, key type, and +HMAC fingerprint—not the raw key. + +- [ ] **Step 2: Implement metadata-only updates** + +Call: + +```ts +updateActiveObservation({ + metadata: { + cacheOutcome: telemetry.cacheOutcome, + matchedBy: telemetry.matchedBy, + companyId: telemetry.companyId, + cacheVersion: telemetry.version, + cacheLastSyncedAt: telemetry.lastSyncedAt, + cacheLookupDurationMs: telemetry.lookupDurationMs, + conflictingCompanyIds: telemetry.conflictingCompanyIds, + cacheKeyType: telemetry.keyType, + cacheKeyFingerprint: telemetry.keyFingerprint, + }, +}); +``` + +Reuse the existing Langfuse enabled check. Do not put cache metadata into +`output`, because workflow outcome updates already own that field. + +- [ ] **Step 3: Start the trace before cache lookup and instrument branches** + +Allow `ResearchTraceContext.companyId` to be optional at trace start. Wrap cache +lookup in the existing `traceResearch` scope, measure duration with +`performance.now()`, then call `updateResearchCacheOutcome` for hit, miss, +suggestions, select, refresh, bypass, conflict, and corrupt-cache recovery. + +Create LangChain/LangGraph callbacks only in the miss/refresh pipeline branch +after the company ID is resolved. A hit still gets one research trace without +provider spans. + +- [ ] **Step 4: Add route telemetry assertions** + +For hit, suggestions, miss, refresh, conflict, and invalid cache, assert exactly +one call containing the expected `cacheOutcome`. For a conflict, assert the +mock received an HMAC fingerprint and never received the raw test tax ID/domain. + +- [ ] **Step 5: Run observability/route suites** + +Run: + +```bash +npm test -- tests/unit/langfuse-observability.test.ts \ + tests/unit/research-cache-route.test.ts \ + tests/unit/research-route-observability.test.ts +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit cache telemetry** + +```bash +git add src/observability/langfuse.ts src/app/api/research/route.ts \ + tests/unit/langfuse-observability.test.ts tests/unit/research-cache-route.test.ts +git commit -m "feat(observability): trace cache outcomes" +``` + +### Task 3: Close the security/error regression matrix + +**Files:** + +- Modify: `tests/unit/research-cache-route.test.ts` +- Modify: `tests/integration/supabase-cache-concurrency.test.ts` + +**Interfaces:** No production interface change; this task proves approved +negative-path contracts. + +- [ ] **Step 1: Verify route tests contain all named regressions** + +Add any missing test so the suite explicitly contains these behaviors: + +1. `503 cache_unavailable` and zero paid-provider factory calls. +2. Corrupt JSONB emits `cache_invalid`, then runs/persists fresh research. +3. `select` rejects an existing unrelated company ID. +4. `refresh` rejects an existing unrelated company ID. +5. Pre-stream strong-key conflict returns `409`. +6. Post-pipeline conflict emits `error(identity_conflict)` then `done`, with no + final profile/diff/analysis events. +7. Persistence failure emits `error(persist_failed)` then `done`, with no final + profile/diff/analysis events. +8. Every successful hit/miss/suggestion path emits exactly one `done`. + +Use provider-construction spies in cases 1, 3, 4, and 5. + +- [ ] **Step 2: Verify Data API permissions with anon and service clients** + +In the local Supabase integration suite, create separate anon and service-role +clients. Assert anon cannot select/insert/update cache tables and cannot execute +the three cache RPCs. Assert the service client can execute the intended RPCs. +Check PostgREST error codes rather than matching English error text. + +- [ ] **Step 3: Rerun concurrency and rollback checks** + +Run two independent service clients concurrently as defined in Sprint 02. Also +force a post-pipeline tax-ID conflict inside `persist_research_snapshot` and +assert profile, report, diff, and identity update all roll back. + +- [ ] **Step 4: Run focused security gates** + +Run: + +```bash +npm test -- tests/unit/research-cache-route.test.ts +eval "$(npx supabase status -o env)" +SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \ +SUPABASE_TEST_ANON_KEY="$ANON_KEY" npm run test:db +npx supabase db advisors --local +``` + +Expected: all regression, permission, concurrency, rollback, and advisor checks +pass. + +- [ ] **Step 5: Commit the hardening tests** + +```bash +git add tests/unit/research-cache-route.test.ts tests/integration/supabase-cache-concurrency.test.ts +git commit -m "test(cache): cover security failure paths" +``` + +### Task 4: Document operations and execute release verification + +**Files:** + +- Modify: `README.md` + +**Interfaces:** Documents supported configuration and operator-visible cache +behavior; no runtime interface change. + +- [ ] **Step 1: Document cache configuration and behavior** + +Add concise README sections covering: + +- Required `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and optional + `CACHE_TELEMETRY_HMAC_SECRET`. +- Cache hit/miss/suggestions/manual refresh behavior. +- Tax ID/domain/name matching safety rules. +- Local Supabase start/reset/database-test commands. +- Cache entries have no TTL. +- Identity merge, first-miss stampede prevention, partial pipeline resume, and + refresh rate limiting are not implemented. +- Raw service-role/HMAC secrets are server-only. + +- [ ] **Step 2: Run the complete repository gate** + +Run: + +```bash +npm test +npm run lint +npm run typecheck +npm run build +npx supabase migration list --local +npx supabase db advisors --local +eval "$(npx supabase status -o env)" +SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \ +SUPABASE_TEST_ANON_KEY="$ANON_KEY" npm run test:db +git diff --check +``` + +Expected: every command passes with no warning attributable to the cache +feature. + +- [ ] **Step 3: Repeat visual acceptance checks** + +Use the local app and local Supabase to verify: + +1. First query miss persists a complete snapshot. +2. Second safe query hit performs no provider call and displays freshness. +3. Name-only query shows suggestions and requires confirmation. +4. Rejecting suggestions starts new research. +5. Refresh creates the next version/diff. +6. Invalid cache recovers visibly; fatal persistence stops cleanly. + +Capture final screenshots and compare them with Sprint 04 screenshots for +unexpected layout regressions. + +- [ ] **Step 4: Commit operational documentation** + +```bash +git add README.md +git commit -m "docs(cache): document operations and limits" +``` + +## Sprint 05 release gate + +The feature is ready for branch integration only when: + +- all Sprint 05 commands pass; +- Supabase advisors are clean; +- anon/authenticated direct access is denied; +- service-role access is server-only; +- HMAC telemetry contains no raw keys; +- hit/miss/suggestion/refresh/error traces are observable; +- all SSE paths terminate; +- final UI screenshots are reviewed; +- `git status --short` is empty. + +Skipped by design: refresh rate limiting, full first-miss stampede prevention, +TTL, automatic identity merge, and partial pipeline resume. Add them only in a +separate approved spec when production evidence justifies the extra machinery. diff --git a/docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md b/docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md new file mode 100644 index 0000000..f95bc59 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md @@ -0,0 +1,534 @@ +# Supabase Research Cache Design + +**Date:** 2026-08-26 + +**Status:** Draft for final review + +**Scope:** Shared, non-expiring research cache backed by Supabase + +## 1. Goal + +Return previously completed company research without running Serper, scraping, +profile synthesis, or analysis again. When no safe cache match exists, run the +current research pipeline and persist a complete snapshot for later requests. + +Cache entries do not expire. A user refreshes a cached company explicitly when +new research is required. + +## 2. Current Context + +The research endpoint streams a LangGraph workflow over SSE. The workflow +currently loads and writes versioned `CompanyProfile` and `ProfileDiff` records +through `StorageAdapter`, but it still executes all source and LLM work before +loading the previous profile. Supabase stores profiles and diffs; it does not +store `AnalysisReport`. Company identity currently falls back to +`slugify(input.name)`, which is not safe as a canonical identifier. + +Relevant code: + +- `src/app/api/research/route.ts` +- `src/app/hooks/use-research.ts` +- `src/app/page.tsx` +- `src/modules/workflow/index.ts` +- `src/adapters/storage/types.ts` +- `src/adapters/storage/supabase.ts` +- `src/adapters/storage/memory.ts` +- `src/config/index.ts` +- `src/lib/types.ts` +- `src/observability/langfuse.ts` + +## 3. Architectural Decision + +Use a read-through flow at the research route with a dedicated research-cache +module. The cache module is the seam between request identity, Supabase lookup, +and complete research snapshots. + +The route performs cache resolution before constructing the LLM, search, +scraper, registry, profile, analyst, or workflow modules. A cache hit therefore +does not require provider credentials and cannot accidentally call a paid +provider. + +The cache module exposes the minimum interface needed by callers: + +- Resolve an input into a hit, suggestions, a miss, or an identity conflict. +- Resolve or create the canonical identity used by a pipeline run. +- Persist one complete profile/report/diff snapshot. + +Supabase remains the persistent shared store. No browser `localStorage` cache +and no additional in-process LRU cache are part of this change. + +## 4. Data Model + +### 4.1 `company_identities` + +Add one canonical identity row per known company: + +| Column | Type | Rules | +|---|---|---| +| `id` | `text` | Primary key. Existing IDs are retained; new IDs are UUID strings. | +| `tax_id` | `text` | Nullable normalized tax ID. | +| `normalized_domain` | `text` | Nullable normalized hostname. Not unique. | +| `normalized_name` | `text` | Required normalized company name. Not unique. | +| `created_at` | `timestamptz` | Creation time. | +| `updated_at` | `timestamptz` | Last identity metadata change, not research freshness. | + +Required indexes and constraints: + +- Primary key on `id`. +- Partial unique index on `tax_id` where `tax_id IS NOT NULL`. +- Non-unique index on `normalized_domain`. +- Non-unique index on `normalized_name`. + +`normalized_domain` is intentionally non-unique because multiple legal entities +may use one corporate domain. `normalized_name` is never used as an automatic +cache hit. + +### 4.2 `company_profiles` + +Keep versioned rows keyed by `(id, version)` and add: + +- `analysis_report JSONB NULL`. +- Foreign key `id → company_identities(id)` after existing data is backfilled. +- Partial lookup index `(id, version DESC) WHERE analysis_report IS NOT NULL`. + +`company_profiles.updated_at` is the research synchronization time exposed to +the client as `lastSyncedAt`. It is distinct from +`company_identities.updated_at`. + +A row is cacheable only when `analysis_report IS NOT NULL`. The current analyst +produces one structured report after a single `completeStructured` call; partial +report streaming is not supported. Therefore a separate completion-status +column is unnecessary. + +### 4.3 `company_diffs` + +Keep the existing table and add a foreign key from `company_id` to +`company_identities(id)`. A cached snapshot includes only the diff whose +`to_version` equals the selected profile version; version 1 returns `null`. + +### 4.4 Backfill + +Backfill one `company_identities` row for each existing distinct profile ID. +Derive identity fields from the latest profile version. Preserve every existing +profile ID so existing profile/diff references remain valid. New companies use +`crypto.randomUUID()` converted to a string; `slugify(name)` is no longer an +identity source. + +## 5. Normalization Rules + +Normalization happens at the cache trust boundary before lookup or persistence. +Input and pipeline-derived identity values use the same functions. + +### Tax ID + +- Trim surrounding whitespace. +- Remove spaces, dots, and hyphens. +- Accept only 10 or 13 decimal digits after normalization. +- Return `null` for an absent value; reject a present malformed value. + +### Domain + +- Parse the already URL-validated website with the platform `URL` class. +- Use `hostname`, lowercase it, remove a trailing dot, and remove one leading + `www.`. +- Ignore scheme, credentials, port, path, query, and fragment. +- Return `null` when no website is supplied. + +### Company name + +- Apply Unicode NFKC normalization. +- Trim, lowercase using the Vietnamese locale, and collapse consecutive + whitespace. +- Retain Vietnamese diacritics, punctuation, and legal suffixes such as `TNHH` + and `CP`. +- Never use a name match as an automatic cache hit. + +## 6. Lookup Rules + +Lookup uses the following order: + +1. `taxId` +2. normalized domain +3. normalized name + +Decision rules: + +| Condition | Result | +|---|---| +| Tax ID resolves to one identity and supplied domain is compatible | Automatic hit | +| Domain resolves to exactly one identity and no tax ID conflicts | Automatic hit | +| Domain resolves to multiple identities | Suggestions requiring confirmation | +| Name resolves to one or more identities | Suggestions requiring confirmation | +| No layer resolves | Miss; run the pipeline immediately | +| Supplied tax ID and domain resolve to different identities | `identity_conflict` | +| Cached profile/report/diff fails runtime validation | `cache_invalid`; treat as miss | + +When a tax ID resolves to identity A, a supplied domain is compatible when it +has no candidates or when every domain candidate set used for this request +contains A. A domain candidate set that excludes A is an identity conflict. A +multi-candidate domain without a tax ID remains a suggestion result. + +No automatic identity merge is permitted. Conflicts require corrected user +input or later manual/admin resolution. + +## 7. Request Contract + +The request is a discriminated union: + +```typescript +type ResearchRequest = + | { input: CompanyInput; cache?: undefined } + | { + input: CompanyInput; + cache: { action: "select"; companyId: string }; + } + | { + input: CompanyInput; + cache: { action: "refresh"; companyId: string }; + } + | { + input: CompanyInput; + cache: { action: "bypass" }; + }; +``` + +`select` validation recomputes the full suggestion candidate set from the +supplied input, including ambiguous-domain and normalized-name candidates, and +accepts the requested `companyId` only when it belongs to that set. Merely +checking that the ID exists is insufficient. + +`refresh` resolves the supplied input through the same tax ID → domain → name +chain. The selected `companyId` must be compatible with the result. A mismatch +returns an explicit conflict; it never silently falls back to bypass. + +`bypass` is used only after a user rejects non-empty suggestions. A normal +zero-result lookup starts the pipeline immediately without an extra client +round trip. + +## 8. SSE Contract + +Add these events while retaining the existing profile, diff, analysis, error, +and done events: + +```typescript +type CacheHitMatchedBy = "tax_id" | "domain" | "selected"; + +type CacheSuggestion = { + companyId: string; + officialName: string; + taxId?: string; + domain?: string; + lastSyncedAt: string; +}; + +type CacheStreamEvent = + | { + event: "cache:hit"; + data: { + companyId: string; + matchedBy: CacheHitMatchedBy; + version: number; + lastSyncedAt: string; + }; + } + | { + event: "cache:suggestions"; + data: { suggestions: CacheSuggestion[] }; + }; +``` + +A cache hit streams, in order: + +1. `cache:hit` +2. `profile:ready` +3. `diff:ready` +4. `analysis:ready` +5. `done` + +Every SSE execution path terminates explicitly. A fatal streaming failure emits +`error` and then `done`; the server never silently closes a stream it has +started. + +Extend the existing error event: + +```typescript +type ResearchErrorCode = + | "identity_conflict" + | "cache_invalid" + | "persist_failed" + | "research_failed"; + +type ResearchErrorEvent = { + event: "error"; + data: { + code?: ResearchErrorCode; + message: string; + source?: SourceName; + }; +}; +``` + +`cache_invalid` is recoverable: emit the error event, continue with a cache +miss, and finish with the pipeline's normal final events. `persist_failed` and +post-pipeline `identity_conflict` are fatal: emit the error event followed by +`done`, with no profile/diff/analysis final events. + +## 9. Server Flow + +### Default lookup + +1. Parse and validate `ResearchRequest`. +2. Construct only the storage/cache dependencies. +3. Normalize input and perform lookup. +4. Return HTTP errors that occur before SSE starts. +5. On hit, validate and stream the complete snapshot without constructing paid + providers. +6. On suggestions, stream the candidates and `done`. +7. On an empty lookup, resolve/create an identity and run the pipeline. + +### Pipeline miss + +1. Resolve or create a canonical identity. +2. Construct LLM, search, scraper, registry, profile, analyst, and workflow + modules only after the miss is confirmed. +3. Pass `companyId` and `existingProfile` into the workflow. The workflow does + not derive identity with `slugify(name)` and does not repeat the cache read. +4. Stream source and build progress only; hold final profile/diff/analysis + events until validation and persistence succeed. +5. Normalize the tax ID/domain/name found in the completed profile and validate + them against `company_identities` again. +6. Persist identity metadata, profile, report, and matching diff atomically. +7. Stream final profile, diff, analysis, and done events. + +### Refresh + +1. Revalidate the client-provided company ID against the current input. +2. Load its latest complete snapshot as the previous profile. +3. Bypass the cache response and run the pipeline under the same canonical ID. +4. Persist the next version and its diff. + +### Post-pipeline conflict + +If newly discovered identity values belong to another company, do not merge, +persist, or stream the completed result. Record conflict telemetry, emit +`error.code = "identity_conflict"`, then emit `done`. + +## 10. Database Concurrency + +Use database RPCs because `supabase-js` cannot group multiple PostgREST calls +into one client-controlled transaction. + +### Resolve/create identity RPC + +The RPC uses separate branches: + +- With tax ID: rely on the partial unique index and `INSERT ... ON CONFLICT` + behavior. Re-read and validate the winning identity; do not overwrite or + merge conflicting domain/name data automatically. +- Without tax ID but with domain: acquire + `pg_advisory_xact_lock(hashtext(normalized_domain))`, recheck the domain/name, + and insert or reuse inside the same transaction. +- Name only: create a UUID identity after suggestions have been rejected or no + suggestions exist. Duplicate name-only identities remain possible because a + name is intentionally not treated as unique. + +The lock is transaction-scoped and releases automatically on commit or +rollback. A session-level advisory lock is forbidden. + +The identity lock prevents duplicate identity rows; it does not prevent two +simultaneous cache misses from both running the expensive pipeline. Profile and +diff upserts preserve database integrity if this occurs, but duplicate provider +cost remains a documented ceiling. + +### Persist snapshot RPC + +Persist the identity metadata update, complete profile/report row, and matching +diff in one transaction. Revalidate pipeline-derived keys in this transaction +before writing. Any conflict or write failure rolls back the whole snapshot. + +## 11. Runtime Validation + +Validate Supabase JSONB before it crosses the cache interface. The runtime +schema covers the complete `CompanyProfile`, `AnalysisReport`, and optional +`ProfileDiff`, including version/company-ID agreement. + +An invalid snapshot: + +1. Emits `cache_invalid` telemetry. +2. Emits a recoverable SSE error when the stream has started. +3. Is treated as a miss. +4. Is never returned to the client as a cache hit. + +## 12. HTTP Error Contract + +Errors detected before the SSE response use JSON: + +```typescript +type ResearchHttpError = { + error: { + code: + | "invalid_request" + | "invalid_cache_selection" + | "identity_conflict" + | "cache_unavailable"; + message: string; + }; +}; +``` + +| Status | Code | Behavior | +|---|---|---| +| `400` | `invalid_request` | Malformed input or cache action. | +| `400` | `invalid_cache_selection` | Existing company ID is not a candidate for the current input. | +| `409` | `identity_conflict` | Strong identifiers disagree. No provider is constructed or called. | +| `503` | `cache_unavailable` | Supabase lookup/RPC is unavailable. No provider is constructed or called. | + +The client handles JSON errors before opening/consuming SSE and handles SSE +errors after a stream begins. In both cases it reaches an explicit terminal UI +state. + +## 13. Telemetry + +Reuse the existing Langfuse integration. Do not add a logger or observability +dependency. + +Record: + +- `cacheOutcome`: `hit`, `miss`, `suggestions`, `refresh`, `bypass`, `conflict`, + or `invalid`. +- `matchedBy`: `tax_id`, `domain`, `normalized_name`, or `selected`. +- Resolved company ID, selected profile version, last synchronization time, and + lookup duration. +- On conflict: both company IDs, key type, and a keyed fingerprint. Never emit + the raw tax ID or domain. + +Use Node's built-in `crypto.createHmac("sha256", secret)` with the dedicated +server secret `CACHE_TELEMETRY_HMAC_SECRET`. A plain SHA-256 hash is forbidden +for tax IDs because their input space is small. Use the same HMAC helper for +domains for consistency. If the secret is absent, omit the fingerprint rather +than logging the raw value or failing the research request. + +## 14. Client Experience + +- A cache hit displays the existing result immediately, its last synchronized + time, and a visible “Cập nhật lại” action. +- Name or ambiguous-domain suggestions show company name, tax ID when present, + domain, and last synchronized time. +- Selecting a suggestion submits `action: "select"`. +- Rejecting non-empty suggestions submits `action: "bypass"`. +- Refresh submits `action: "refresh"` for the currently displayed company. +- Recoverable `cache_invalid` informs the user that cached data was unusable and + that fresh research is running. +- Fatal errors stop loading and show the server message. + +## 15. Verification Strategy + +Use the existing Vitest suite and its current mock-adapter patterns. Do not add +a test framework. + +### Unit tests + +- Tax ID, domain, and name normalization. +- Lookup priority and decision table. +- Tax/domain conflict detection. +- Ambiguous domain and name suggestions. +- Complete-snapshot runtime validation. +- Latest complete profile selection and diff `to_version` matching. +- HMAC fingerprint determinism, secret separation, and omission without a + secret. + +### Route tests + +- Cache hit streams the complete event sequence and never constructs/calls LLM, + Serper, or scraper adapters. +- Supabase/RPC failure returns `503 cache_unavailable` and never + constructs/calls paid providers. +- A corrupt snapshot emits `cache_invalid`, becomes a miss, and runs the + pipeline. +- `select` rejects an existing, valid company ID that is not in the current + input's suggestion set. +- `refresh` rejects an existing, valid company ID belonging to an unrelated + company. +- An empty lookup starts the pipeline without a bypass round trip. +- A pre-stream identity conflict returns `409`. +- A post-pipeline conflict emits `error.code = "identity_conflict"`, then + `done`, and emits no final profile/diff/analysis events. +- Persist failure emits `error.code = "persist_failed"`, then `done`, and emits + no final profile/diff/analysis events. + +### Workflow tests + +- Workflow uses the supplied canonical company ID and previous profile. +- Refresh creates the next version and a diff whose `toVersion` matches it. +- Workflow no longer reads existing data via `slugify(name)`. + +### Database integration tests + +- Two genuinely separate database clients/connections invoke the domain-only + resolve/create RPC concurrently for the same normalized domain and name. +- Assert both calls return the same identity and only one matching identity row + exists. +- A sequential or same-connection test is insufficient because it cannot prove + the advisory lock works under concurrent transactions. +- Verify tax-ID `ON CONFLICT`, transactional rollback, the partial complete-row + index query, and snapshot/diff foreign-key integrity. + +### UI verification + +- Verify name suggestions, rejecting suggestions, selecting a cached company, + last-synchronized time, recoverable cache-invalid state, and refresh. +- Capture before/after screenshots for the changed UI states. + +### Final verification + +- Full Vitest suite. +- Lint. +- Typecheck. +- Production build. + +## 16. Security Properties + +- Client-supplied company IDs are always rebound to the current normalized + input before read or refresh. +- Strong-identifier conflicts fail closed and never merge identities. +- Cached JSONB is runtime-validated before use. +- Paid providers are not constructed on hit, invalid selection, conflict, or + cache-backend failure. +- Advisory locks are transaction-scoped. +- Telemetry never stores raw tax IDs or domains. +- Database functions use `SECURITY INVOKER` by default, explicitly qualify + referenced schemas, and receive only the grants required by the server role. +- Existing Supabase keys remain server-only; no service-role key is exposed to + the browser. + +## 17. Deferred Scope + +- TTL or automatic freshness invalidation. +- In-process LRU/cache layer in front of Supabase. +- Automatic identity merge or an admin merge interface. +- Resuming analysis from partially persisted findings/profile data. +- Full cache-stampede prevention for concurrent first misses. +- Refresh rate limiting. Until a later phase adds per-user/company limits, the + existing global research guards remain the only cost ceiling; this risk must + be revisited before exposing refresh to untrusted high-volume traffic. + +## 18. Acceptance Criteria + +The design is complete when all of the following are true: + +1. A safe cache hit returns profile, matching diff, and analysis without + constructing or calling paid providers. +2. A miss runs the existing pipeline and atomically stores a complete reusable + snapshot. +3. Name matches and ambiguous domains require server-validated user selection. +4. Refresh is explicit, server-bound to the supplied input, and creates a new + version/diff. +5. Identity conflicts never auto-merge and always produce observable terminal + errors. +6. Supabase failure cannot trigger an expensive uncached run. +7. Concurrent domain-only identity creation is verified with independent + database connections. +8. Every SSE path ends with `done`, including fatal errors. +9. The UI displays cache age and offers manual refresh. +10. Existing research, export, observability, and storage tests continue to + pass. diff --git a/package-lock.json b/package-lock.json index 75a013b..9a4a4a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@typescript/native": "npm:typescript@7.0.2", "eslint": "^9", "eslint-config-next": "16.3.2", + "supabase": "2.115.0", "tailwindcss": "^4", "typescript": "npm:@typescript/typescript6@6.0.2", "vitest": "^4.1.11" @@ -306,6 +307,21 @@ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, + "node_modules/@ecies/ciphers": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1621,6 +1637,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2738,6 +2770,130 @@ "node": ">=22.0.0" } }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.115.0.tgz", + "integrity": "sha512-yUNw1KG+fyuBqBGvFT8ASC7aAkFkx2Kx+qPjUTW50ttiKKgg8D/eMSCoSjQuaAbL0vafw98bHEZ2dAfdyYyXMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.115.0.tgz", + "integrity": "sha512-e4bbWADYcjSjXgSSErreoqyEeEjrFQunxCYummUoiGdVanck/itAIFwhtRykKpBtoKqCYh4CFxUiDxOFMBdNHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.115.0.tgz", + "integrity": "sha512-JBcnnFuVekMR9+EOCcup1QihW+CHMBWcL/+N1Uz4HB6leX8d894VQ+sX0pFgbXLqnpGcc35IiXKwjLz4FCb8Dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.115.0.tgz", + "integrity": "sha512-2OCzD4qZx8RFbW2vfOlXpup+1UnVqSauUsSGH4mKiUIveMD/UyMI6Md8CLKLszwV2XwM+6bZG6w7d7Ems9vxCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.115.0.tgz", + "integrity": "sha512-ZvZ5QbPB3cvenEam6TDgngWPPm9GBO5m/5GYCOsqIfx2Gz5++WsEhlypWy9U4gTFae7ENtih0RqPzgiyWDKE+w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.115.0.tgz", + "integrity": "sha512-t36QEEQxy0AsOn0rr1L8aEzZKYS+kRH4fvYr5KdB+rHA1x39A1dUeOCTqCucCr0V5oQ3+odJLlO6tinNczHKMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.115.0.tgz", + "integrity": "sha512-MkYbWrNZXpWZxwglVaTexj+w6EWYUi34c7Y9SRGbcQBC/infteM/8zhVVRUmO3ltSEqnJCacHowLPISXIF6g/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.115.0.tgz", + "integrity": "sha512-jRXsJjbw/h0ssSpFQeClTTO2wtMw0YYS1yMzN2f/px4DehigBZNxSFB4thOtzmIJoisInm1gYV5pQJZla1Ytpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@supabase/functions-js": { "version": "2.112.3", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.3.tgz", @@ -5148,6 +5304,24 @@ "node": ">= 0.4" } }, + "node_modules/eciesjs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.5.0.tgz", + "integrity": "sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.6", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.412", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", @@ -6957,6 +7131,16 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -9003,6 +9187,30 @@ } } }, + "node_modules/supabase": { + "version": "2.115.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.115.0.tgz", + "integrity": "sha512-8fL9vOd6jOntmU8N5DVlHGE2GWR1r57ulsrOzSyO6IRYq5QMyKie8T8DH+hb+caGhYUVJLvmpY7XYwic60Uafg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eciesjs": "^0.5.0", + "jose": "^6.2.8" + }, + "bin": { + "supabase": "dist/supabase.js" + }, + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.115.0", + "@supabase/cli-darwin-x64": "2.115.0", + "@supabase/cli-linux-arm64": "2.115.0", + "@supabase/cli-linux-arm64-musl": "2.115.0", + "@supabase/cli-linux-x64": "2.115.0", + "@supabase/cli-linux-x64-musl": "2.115.0", + "@supabase/cli-windows-arm64": "2.115.0", + "@supabase/cli-windows-x64": "2.115.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index b6bf049..352bacc 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run", + "test:db": "vitest run tests/integration/supabase-cache-concurrency.test.ts", "typecheck": "next typegen && tsc --noEmit", "typecheck:legacy": "next typegen && tsc6 --noEmit", "postinstall": "node -e \"const fs=require('fs'),p='node_modules/typescript/package.json';if(fs.existsSync(p)){const j=JSON.parse(fs.readFileSync(p,'utf8'));if(!j.bin||!j.bin.tsc){j.bin=j.bin||{};j.bin.tsc='./bin/tsc';fs.writeFileSync(p,JSON.stringify(j,null,2));const b='node_modules/typescript/bin/tsc';if(!fs.existsSync(b)){fs.writeFileSync(b,'#!/usr/bin/env node\\nrequire(\\'../../@typescript/native/bin/tsc\\');\\n',{mode:0o755});}}}\"" @@ -37,6 +38,7 @@ "@typescript/native": "npm:typescript@7.0.2", "eslint": "^9", "eslint-config-next": "16.3.2", + "supabase": "2.115.0", "tailwindcss": "^4", "typescript": "npm:@typescript/typescript6@6.0.2", "vitest": "^4.1.11" diff --git a/public/architecture-light.png b/public/architecture-light.png new file mode 100644 index 0000000..5662874 Binary files /dev/null and b/public/architecture-light.png differ diff --git a/src/adapters/storage/memory.ts b/src/adapters/storage/memory.ts index 771d797..7c2615a 100644 --- a/src/adapters/storage/memory.ts +++ b/src/adapters/storage/memory.ts @@ -2,7 +2,16 @@ // In-Memory Storage Adapter — for development & testing // ═══════════════════════════════════════════════════════ -import type { CompanyProfile, ProfileDiff } from "@/lib/types"; +import type { + CompanyProfile, + ProfileDiff, + ResearchSnapshot, +} from "@/lib/types"; +import { + type IdentityCandidate, + type NormalizedCompanyIdentity, + IdentityConflictError, +} from "@/modules/cache"; import type { StorageAdapter, StorageReadOptions, @@ -10,9 +19,10 @@ import type { } from "./types"; export class MemoryStorageAdapter implements StorageAdapter { - // companyId → version → profile + private identities: Map = new Map(); private profiles: Map> = new Map(); - // companyId → diffs + private reports: Map> = new Map(); + private timestamps: Map> = new Map(); private diffs: Map = new Map(); async saveProfile( @@ -23,7 +33,7 @@ export class MemoryStorageAdapter implements StorageAdapter { if (!this.profiles.has(profile.id)) { this.profiles.set(profile.id, new Map()); } - this.profiles.get(profile.id)!.set(profile.version, profile); + this.profiles.get(profile.id)!.set(profile.version, structuredClone(profile)); } async getProfile( @@ -34,10 +44,10 @@ export class MemoryStorageAdapter implements StorageAdapter { if (!versions) return null; if (version !== undefined) { - return versions.get(version) ?? null; + const p = versions.get(version); + return p ? structuredClone(p) : null; } - // Return latest return this.getLatestProfile(companyId); } @@ -50,7 +60,8 @@ export class MemoryStorageAdapter implements StorageAdapter { if (!versions || versions.size === 0) return null; const maxVersion = Math.max(...versions.keys()); - return versions.get(maxVersion) ?? null; + const p = versions.get(maxVersion); + return p ? structuredClone(p) : null; } async listProfiles(): Promise { @@ -58,7 +69,7 @@ export class MemoryStorageAdapter implements StorageAdapter { for (const versions of this.profiles.values()) { const maxVersion = Math.max(...versions.keys()); const latest = versions.get(maxVersion); - if (latest) result.push(latest); + if (latest) result.push(structuredClone(latest)); } return result; } @@ -71,16 +82,210 @@ export class MemoryStorageAdapter implements StorageAdapter { if (!this.diffs.has(diff.companyId)) { this.diffs.set(diff.companyId, []); } - this.diffs.get(diff.companyId)!.push(diff); + this.diffs.get(diff.companyId)!.push(structuredClone(diff)); } async getDiffs(companyId: string): Promise { - return this.diffs.get(companyId) ?? []; + const d = this.diffs.get(companyId) ?? []; + return structuredClone(d); + } + + async findIdentityCandidates( + identity: NormalizedCompanyIdentity, + options?: StorageReadOptions, + ): Promise { + options?.signal?.throwIfAborted(); + const result: IdentityCandidate[] = []; + for (const cand of this.identities.values()) { + const matchTax = identity.taxId !== null && cand.taxId === identity.taxId; + const matchDomain = identity.domain !== null && cand.domain === identity.domain; + const matchName = cand.name === identity.name; + if (matchTax || matchDomain || matchName) { + result.push(structuredClone(cand)); + } + } + return result.sort((a, b) => a.companyId.localeCompare(b.companyId)); + } + + async getLatestCompleteSnapshot( + companyId: string, + options?: StorageReadOptions, + ): Promise { + options?.signal?.throwIfAborted(); + const profileMap = this.profiles.get(companyId); + const reportMap = this.reports.get(companyId); + if (!profileMap || !reportMap) return null; + + const completeVersions = Array.from(profileMap.keys()) + .filter((v) => reportMap.has(v)) + .sort((a, b) => b - a); + + if (completeVersions.length === 0) return null; + const latestVersion = completeVersions[0]; + const profile = structuredClone(profileMap.get(latestVersion)!); + const report = structuredClone(reportMap.get(latestVersion)!); + const companyDiffs = this.diffs.get(companyId) ?? []; + const diff = companyDiffs.find((d) => d.toVersion === latestVersion) ?? null; + const lastSyncedAt = + this.timestamps.get(companyId)?.get(latestVersion) ?? + profile.lastUpdated.toISOString(); + + return { + profile, + report, + diff: diff ? structuredClone(diff) : null, + lastSyncedAt, + }; + } + + async resolveOrCreateIdentity( + identity: NormalizedCompanyIdentity, + candidateId: string, + options?: StorageWriteOptions, + ): Promise { + options?.signal?.throwIfAborted(); + // 1. If taxId provided + if (identity.taxId) { + let taxOwnerId: string | null = null; + for (const cand of this.identities.values()) { + if (cand.taxId === identity.taxId) { + taxOwnerId = cand.companyId; + break; + } + } + + if (taxOwnerId && identity.domain) { + const domainMatches = Array.from(this.identities.values()).filter( + (c) => c.domain === identity.domain + ); + if ( + domainMatches.length > 0 && + !domainMatches.some((c) => c.companyId === taxOwnerId) + ) { + throw new IdentityConflictError(); + } + } + + if (taxOwnerId) { + return taxOwnerId; + } + + this.identities.set(candidateId, { + companyId: candidateId, + taxId: identity.taxId, + domain: identity.domain, + name: identity.name, + }); + return candidateId; + } + + // 2. If domain provided + if (identity.domain) { + for (const cand of this.identities.values()) { + if (cand.domain === identity.domain && cand.name === identity.name) { + return cand.companyId; + } + } + + this.identities.set(candidateId, { + companyId: candidateId, + taxId: null, + domain: identity.domain, + name: identity.name, + }); + return candidateId; + } + + // 3. Name only + this.identities.set(candidateId, { + companyId: candidateId, + taxId: null, + domain: null, + name: identity.name, + }); + return candidateId; + } + + async persistResearchSnapshot( + identity: NormalizedCompanyIdentity, + snapshot: Omit, + options?: StorageWriteOptions, + ): Promise { + options?.signal?.throwIfAborted(); + const companyId = snapshot.profile.id; + + // Check conflict + if (identity.taxId) { + for (const cand of this.identities.values()) { + if (cand.taxId === identity.taxId && cand.companyId !== companyId) { + throw new IdentityConflictError(); + } + } + } + + // Update / insert identity + const existingIdentity = this.identities.get(companyId); + this.identities.set(companyId, { + companyId, + taxId: identity.taxId ?? existingIdentity?.taxId ?? null, + domain: identity.domain ?? existingIdentity?.domain ?? null, + name: identity.name || existingIdentity?.name || "", + }); + + const nowIso = new Date().toISOString(); + const version = snapshot.profile.version; + + // Save profile + if (!this.profiles.has(companyId)) { + this.profiles.set(companyId, new Map()); + } + const profileToSave = { + ...snapshot.profile, + lastUpdated: new Date(nowIso), + }; + this.profiles.get(companyId)!.set(version, structuredClone(profileToSave)); + + // Save report + if (!this.reports.has(companyId)) { + this.reports.set(companyId, new Map()); + } + this.reports.get(companyId)!.set(version, structuredClone(snapshot.report)); + + // Save timestamp + if (!this.timestamps.has(companyId)) { + this.timestamps.set(companyId, new Map()); + } + this.timestamps.get(companyId)!.set(version, nowIso); + + // Save diff + if (snapshot.diff) { + if (!this.diffs.has(companyId)) { + this.diffs.set(companyId, []); + } + const existingDiffIndex = this.diffs + .get(companyId)! + .findIndex((d) => d.toVersion === version); + if (existingDiffIndex >= 0) { + this.diffs.get(companyId)![existingDiffIndex] = structuredClone(snapshot.diff); + } else { + this.diffs.get(companyId)!.push(structuredClone(snapshot.diff)); + } + } + + return { + profile: structuredClone(profileToSave), + report: structuredClone(snapshot.report), + diff: snapshot.diff ? structuredClone(snapshot.diff) : null, + lastSyncedAt: nowIso, + }; } // Test helpers clear(): void { + this.identities.clear(); this.profiles.clear(); + this.reports.clear(); + this.timestamps.clear(); this.diffs.clear(); } diff --git a/src/adapters/storage/supabase.ts b/src/adapters/storage/supabase.ts index 4576fe2..9896964 100644 --- a/src/adapters/storage/supabase.ts +++ b/src/adapters/storage/supabase.ts @@ -1,16 +1,21 @@ // ═══════════════════════════════════════════════════════ // Supabase PostgreSQL Storage Adapter -// Implements JSONB multi-version storage for CompanyProfile & ProfileDiff +// Implements JSONB multi-version storage for CompanyProfile, ProfileDiff, and ResearchSnapshot // ═══════════════════════════════════════════════════════ -// Ensure WebSocket constructor exists in Node.js runtimes < 22 for @supabase/realtime-js -if (typeof globalThis !== "undefined" && typeof globalThis.WebSocket === "undefined") { - // @ts-expect-error fallback mock for RealtimeClient in REST-only mode - globalThis.WebSocket = class WebSocket {}; -} - -import { createClient, SupabaseClient } from "@supabase/supabase-js"; -import type { CompanyProfile, ProfileDiff } from "@/lib/types"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { + type CompanyProfile, + type ProfileDiff, + type ResearchSnapshot, + ResearchSnapshotSchema, +} from "@/lib/types"; +import { + type IdentityCandidate, + type NormalizedCompanyIdentity, + IdentityConflictError, + CacheInvalidError, +} from "@/modules/cache"; import type { StorageAdapter, StorageReadOptions, @@ -22,10 +27,15 @@ export class SupabaseStorageAdapter implements StorageAdapter { constructor(supabaseUrl?: string, supabaseKey?: string) { const url = supabaseUrl || process.env.SUPABASE_URL; - const key = supabaseKey || process.env.SUPABASE_ANON_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; + const key = + supabaseKey || + process.env.SUPABASE_SERVICE_ROLE_KEY || + process.env.SUPABASE_ANON_KEY; if (!url || !key) { - throw new Error("Missing Supabase credentials: SUPABASE_URL or SUPABASE_ANON_KEY"); + throw new Error( + "Missing Supabase credentials: SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY / SUPABASE_ANON_KEY" + ); } this.client = createClient(url, key, { @@ -103,7 +113,6 @@ export class SupabaseStorageAdapter implements StorageAdapter { } async listProfiles(): Promise { - // Get unique latest version per company const { data, error } = await this.client .from("company_profiles") .select("data") @@ -116,7 +125,6 @@ export class SupabaseStorageAdapter implements StorageAdapter { if (!data) return []; - // Deduplicate to keep latest version per company id const seen = new Set(); const profiles: CompanyProfile[] = []; @@ -170,4 +178,200 @@ export class SupabaseStorageAdapter implements StorageAdapter { return (data ?? []).map((row) => row.data as ProfileDiff); } + + // ─── Cache & Snapshot RPCs ─── + + async findIdentityCandidates( + identity: NormalizedCompanyIdentity, + options?: StorageReadOptions, + ): Promise { + const query = this.client.rpc("lookup_company_identities", { + p_tax_id: identity.taxId, + p_domain: identity.domain, + p_name: identity.name, + }); + if (options?.signal) query.abortSignal(options.signal); + + const { data, error } = await query; + if (error) { + if ( + error.code === "PGRST202" || + error.code === "PGRST205" || + error.message.includes("schema cache") || + error.message.includes("Could not find the function") || + error.message.includes("Could not find the table") + ) { + console.warn( + "Supabase stored procedures not found in database. Operating in live research mode without cache." + ); + return []; + } + throw new Error(`Failed to lookup company identities: ${error.message}`); + } + + if (!Array.isArray(data)) return []; + + return data.map((row: { id: string; tax_id: string | null; normalized_domain: string | null; normalized_name: string }) => ({ + companyId: row.id, + taxId: row.tax_id, + domain: row.normalized_domain, + name: row.normalized_name, + })); + } + + async getLatestCompleteSnapshot( + companyId: string, + options?: StorageReadOptions, + ): Promise { + const profileQuery = this.client + .from("company_profiles") + .select("version, data, analysis_report, updated_at") + .eq("id", companyId) + .not("analysis_report", "is", null) + .order("version", { ascending: false }) + .limit(1); + + if (options?.signal) profileQuery.abortSignal(options.signal); + + const { data: profileRows, error: profileError } = await profileQuery; + if (profileError) { + if ( + profileError.code === "PGRST205" || + profileError.message.includes("schema cache") || + profileError.message.includes("Could not find the table") + ) { + return null; + } + throw new Error(`Failed to get complete profile: ${profileError.message}`); + } + + if (!profileRows || profileRows.length === 0) return null; + + const row = profileRows[0]; + const version = row.version; + + const diffQuery = this.client + .from("company_diffs") + .select("data") + .eq("company_id", companyId) + .eq("to_version", version); + + if (options?.signal) diffQuery.abortSignal(options.signal); + + const { data: diffRow, error: diffError } = await diffQuery.maybeSingle(); + if (diffError) { + if ( + diffError.code === "PGRST205" || + diffError.message.includes("schema cache") || + diffError.message.includes("Could not find the table") + ) { + // Fallback without diff + } else { + throw new Error(`Failed to get snapshot diff: ${diffError.message}`); + } + } + + const rawSnapshot = { + profile: row.data, + report: row.analysis_report, + diff: diffRow ? diffRow.data : null, + lastSyncedAt: typeof row.updated_at === "string" ? row.updated_at : new Date(row.updated_at).toISOString(), + }; + + const parsed = ResearchSnapshotSchema.safeParse(rawSnapshot); + if (!parsed.success) { + throw new CacheInvalidError( + `Cached snapshot for company ${companyId} failed validation: ${parsed.error.message}` + ); + } + + return parsed.data; + } + + async resolveOrCreateIdentity( + identity: NormalizedCompanyIdentity, + candidateId: string, + options?: StorageWriteOptions, + ): Promise { + const query = this.client.rpc("resolve_company_identity", { + p_tax_id: identity.taxId, + p_domain: identity.domain, + p_name: identity.name, + p_candidate_id: candidateId, + }); + if (options?.signal) query.abortSignal(options.signal); + + const { data, error } = await query; + if (error) { + if (error.message.includes("identity_conflict")) { + throw new IdentityConflictError(); + } + if ( + error.code === "PGRST202" || + error.code === "PGRST205" || + error.message.includes("schema cache") || + error.message.includes("Could not find the function") + ) { + console.warn( + "Supabase stored procedure resolve_company_identity not found. Using candidate ID." + ); + return candidateId; + } + throw new Error(`Failed to resolve company identity: ${error.message}`); + } + + return data as string; + } + + async persistResearchSnapshot( + identity: NormalizedCompanyIdentity, + snapshot: Omit, + options?: StorageWriteOptions, + ): Promise { + const query = this.client.rpc("persist_research_snapshot", { + p_company_id: snapshot.profile.id, + p_tax_id: identity.taxId, + p_domain: identity.domain, + p_name: identity.name, + p_version: snapshot.profile.version, + p_profile_data: snapshot.profile, + p_analysis_report: snapshot.report, + p_diff_data: snapshot.diff, + }); + if (options?.signal) query.abortSignal(options.signal); + + const { data, error } = await query; + if (error) { + if (error.message.includes("identity_conflict")) { + throw new IdentityConflictError(); + } + if ( + error.code === "PGRST202" || + error.code === "PGRST205" || + error.message.includes("schema cache") || + error.message.includes("Could not find the function") + ) { + console.warn( + "Supabase stored procedure persist_research_snapshot not found. Skipping persistence." + ); + return { + profile: snapshot.profile, + report: snapshot.report, + diff: snapshot.diff, + lastSyncedAt: new Date().toISOString(), + }; + } + throw new Error(`Failed to persist research snapshot: ${error.message}`); + } + + const lastSyncedAt = + typeof data === "string" ? data : new Date(data).toISOString(); + + return { + profile: snapshot.profile, + report: snapshot.report, + diff: snapshot.diff, + lastSyncedAt, + }; + } } diff --git a/src/adapters/storage/types.ts b/src/adapters/storage/types.ts index 4c81d61..9cdf68c 100644 --- a/src/adapters/storage/types.ts +++ b/src/adapters/storage/types.ts @@ -1,8 +1,12 @@ -// ═══════════════════════════════════════════════════════ -// Storage Adapter — Interface -// ═══════════════════════════════════════════════════════ - -import type { CompanyProfile, ProfileDiff } from "@/lib/types"; +import type { + CompanyProfile, + ProfileDiff, + ResearchSnapshot, +} from "@/lib/types"; +import type { + NormalizedCompanyIdentity, + IdentityCandidate, +} from "@/modules/cache"; export interface StorageWriteOptions { signal?: AbortSignal; @@ -28,4 +32,28 @@ export interface StorageAdapter { listProfiles(): Promise; saveDiff(diff: ProfileDiff, options?: StorageWriteOptions): Promise; getDiffs(companyId: string): Promise; + + // Cache and complete snapshot methods + findIdentityCandidates( + identity: NormalizedCompanyIdentity, + options?: StorageReadOptions, + ): Promise; + + getLatestCompleteSnapshot( + companyId: string, + options?: StorageReadOptions, + ): Promise; + + resolveOrCreateIdentity( + identity: NormalizedCompanyIdentity, + candidateId: string, + options?: StorageWriteOptions, + ): Promise; + + persistResearchSnapshot( + identity: NormalizedCompanyIdentity, + snapshot: Omit, + options?: StorageWriteOptions, + ): Promise; } + diff --git a/src/app/api/research/route.ts b/src/app/api/research/route.ts index cfebb6a..d663410 100644 --- a/src/app/api/research/route.ts +++ b/src/app/api/research/route.ts @@ -1,12 +1,19 @@ // ═══════════════════════════════════════════════════════ // API Route — Research Endpoint (SSE Streaming) -// Thin adapter: passes request to LangGraph ResearchWorkflow and streams events +// Cache-first: checks Supabase research cache before initializing +// expensive paid providers (Search, Scraper, LLM, Registry). // ═══════════════════════════════════════════════════════ import { NextRequest } from "next/server"; -import { CompanyInputSchema } from "@/lib/types"; -import type { StreamEvent } from "@/lib/types"; -import { createSSEStream } from "@/lib/stream"; +import { + ResearchRequestSchema, + type CompanyInput, + type CompanyProfile, + type StreamEvent, + type SourceName, + type ResearchSnapshot, +} from "@/lib/types"; +import { createSSEStream, type SSEWriter } from "@/lib/stream"; import { createLLMAdapter, createSearchAdapter, @@ -15,136 +22,510 @@ import { createStorageAdapter, getGuards, } from "@/config"; +import type { StorageAdapter } from "@/adapters/storage/types"; +import { + createResearchCache, + normalizeCompanyIdentity, + IdentityConflictError, + InvalidCacheSelectionError, + CacheUnavailableError, + type NormalizedCompanyIdentity, + type ResearchCache, + type CacheResolution, +} from "@/modules/cache"; import { createProfileModule } from "@/modules/profile"; import { createAnalystModule } from "@/modules/analyst"; import { createResearchWorkflow } from "@/modules/workflow"; +import type { ResearchWorkflowState } from "@/modules/workflow/state"; import { createLangfuseCallback, emitResearchScores, flushLangfuse, traceResearch, + updateResearchCacheOutcome, type ResearchTraceContext, updateResearchObservationOutcome, updateResearchTraceOutcome, } from "@/observability/langfuse"; -import { slugify, type SourceName } from "@/lib/types"; export const runtime = "nodejs"; export const maxDuration = 300; export async function POST(req: NextRequest) { + // 1. JSON parsing and schema validation + let body: unknown; try { - const body = await req.json(); - const input = CompanyInputSchema.parse(body); - - const guards = getGuards(); - const llm = createLLMAdapter(); - const search = createSearchAdapter(); - const scraper = createScraperAdapter(); - const registry = createRegistryAdapter(); - const storage = createStorageAdapter(); - - const profile = createProfileModule({ llm }); - const analyst = createAnalystModule({ llm }); - - const workflow = createResearchWorkflow({ - search, - scraper, - registry, - storage, - profile, - analyst, - guards, - }); - - const { stream, writer } = createSSEStream(); - const researchRunId = crypto.randomUUID(); - const companyId = slugify(input.name); - - const traceContext: ResearchTraceContext = { - researchRunId, - companyId, - requestedSources: [ - "web_search", - "website", - "news", - "registry", - ...(input.linkedinUrl ? ["linkedin" as SourceName] : []), - ], - }; - const langfuseCallback = createLangfuseCallback(traceContext); - - // Setup cancellation & 285s internal deadline - const controller = new AbortController(); - const onReqAbort = () => controller.abort(); - req.signal.addEventListener("abort", onReqAbort); - const deadlineTimeout = setTimeout(() => { - controller.abort("Research deadline exceeded (285s)"); - }, 285_000); - - void (async () => { - try { - await traceResearch(traceContext, async (traceId) => { - try { - for await (const event of workflow.stream(input, { - researchRunId, - signal: controller.signal, - callbacks: langfuseCallback ? [langfuseCallback] : undefined, - onComplete: async (state) => { - updateResearchTraceOutcome(state); - await emitResearchScores(traceId, { - sourceResults: state.sourceResults, - hasProfile: Boolean(state.profile), - hasAnalysis: Boolean(state.report), - overallConfidence: state.profile?.overallConfidence ?? 0, - outcome: - state.outcome === "running" ? "failed" : state.outcome, - }); - }, - })) { - writer.write(event); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Internal workflow error"; - updateResearchObservationOutcome( - controller.signal.aborted ? "cancelled" : "failed", - ); + body = await req.json(); + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON in request body" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const parseResult = ResearchRequestSchema.safeParse(body); + if (!parseResult.success) { + return new Response( + JSON.stringify({ + error: "Validation failed", + details: parseResult.error.flatten(), + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const { input, cache } = parseResult.data; + const action = cache?.action ?? "auto"; + const selectedCompanyId = + cache?.action === "select" ? cache.companyId : undefined; + const refreshCompanyId = + cache?.action === "refresh" ? cache.companyId : undefined; + + // 2. Preflight Cache Resolution before opening SSE stream + let autoResolution: CacheResolution | null = null; + let selectedSnapshot: ResearchSnapshot | null = null; + let refreshSnapshot: ResearchSnapshot | null = null; + let storage: StorageAdapter; + let researchCache: ResearchCache; + + try { + storage = createStorageAdapter(); + researchCache = createResearchCache(storage); + + if (action === "auto") { + autoResolution = await researchCache.lookup(input, { + signal: req.signal, + }); + if (autoResolution.kind === "conflict") { + return new Response( + JSON.stringify({ + error: "Thông tin định danh công ty mâu thuẫn.", + code: "identity_conflict", + }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + } + } else if (action === "select") { + if (!selectedCompanyId) { + return new Response( + JSON.stringify({ + error: "Thiếu mã định danh công ty được chọn.", + code: "invalid_cache_selection", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + selectedSnapshot = await researchCache.select(input, selectedCompanyId, { + signal: req.signal, + }); + } else if (action === "refresh") { + if (!refreshCompanyId) { + return new Response( + JSON.stringify({ + error: "Thiếu mã định danh công ty cần làm mới.", + code: "identity_conflict", + }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + } + refreshSnapshot = await researchCache.prepareRefresh( + input, + refreshCompanyId, + { + signal: req.signal, + } + ); + } else if (action === "bypass") { + const norm = normalizeCompanyIdentity(input); + if (norm.taxId) { + const candidates = await storage.findIdentityCandidates(norm, { + signal: req.signal, + }); + const taxMatch = candidates.find((c) => c.taxId === norm.taxId); + if ( + taxMatch && + norm.domain && + taxMatch.domain && + norm.domain !== taxMatch.domain + ) { + return new Response( + JSON.stringify({ + error: + "Không thể bỏ qua cache khi thông tin định danh mâu thuẫn với MST đã đăng ký.", + code: "identity_conflict", + }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + } + } + } + } catch (err) { + if (err instanceof IdentityConflictError) { + return new Response( + JSON.stringify({ error: err.message, code: "identity_conflict" }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + } + if (err instanceof InvalidCacheSelectionError) { + return new Response( + JSON.stringify({ + error: err.message, + code: "invalid_cache_selection", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + if (err instanceof CacheUnavailableError) { + return new Response( + JSON.stringify({ error: err.message, code: "cache_unavailable" }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + const message = + err instanceof Error ? err.message : "Cache preflight failed"; + return new Response( + JSON.stringify({ error: message, code: "cache_unavailable" }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + + // 3. Open SSE Stream + const { stream, writer } = createSSEStream(); + const researchRunId = crypto.randomUUID(); + + // Cancellation & 285s internal deadline + const controller = new AbortController(); + const onReqAbort = () => controller.abort(); + req.signal.addEventListener("abort", onReqAbort); + const deadlineTimeout = setTimeout(() => { + controller.abort("Research deadline exceeded (285s)"); + }, 285_000); + + void (async () => { + try { + if (action === "auto" && autoResolution) { + if (autoResolution.kind === "hit") { + const traceContext: ResearchTraceContext = { + researchRunId, + companyId: autoResolution.snapshot.profile.id, + requestedSources: [], + cacheHit: true, + cacheMatchedBy: autoResolution.matchedBy, + cacheAction: "auto", + }; + await traceResearch(traceContext, async (traceId) => { await emitResearchScores(traceId, { sourceResults: [], - hasProfile: false, - hasAnalysis: false, - overallConfidence: 0, - outcome: "failed", + hasProfile: true, + hasAnalysis: true, + overallConfidence: + autoResolution.snapshot.profile.overallConfidence, + outcome: "complete", }); + }); + + writer.write({ + event: "cache:hit", + data: { + companyId: autoResolution.snapshot.profile.id, + matchedBy: autoResolution.matchedBy, + version: autoResolution.snapshot.profile.version, + lastSyncedAt: autoResolution.snapshot.lastSyncedAt, + }, + } as StreamEvent); + writer.write({ + event: "profile:ready", + data: { profile: autoResolution.snapshot.profile }, + } as StreamEvent); + if (autoResolution.snapshot.diff) { + writer.write({ + event: "diff:ready", + data: { diff: autoResolution.snapshot.diff }, + } as StreamEvent); + } + writer.write({ + event: "analysis:ready", + data: { report: autoResolution.snapshot.report }, + } as StreamEvent); + writer.write({ event: "done", data: {} } as StreamEvent); + return; + } + + if (autoResolution.kind === "suggestions") { + writer.write({ + event: "cache:suggestions", + data: { suggestions: autoResolution.suggestions }, + } as StreamEvent); + writer.write({ event: "done", data: {} } as StreamEvent); + return; + } + + if (autoResolution.kind === "miss") { + if (autoResolution.cacheInvalid) { writer.write({ event: "error", - data: { message }, + data: { + message: + "Dữ liệu cache không hợp lệ, đang tiến hành nghiên cứu mới.", + code: "cache_invalid", + }, } as StreamEvent); - writer.write({ event: "done", data: {} } as StreamEvent); + updateResearchCacheOutcome({ cacheOutcome: "invalid" }); } + + const miss = await researchCache.resolveMiss(input, { + signal: controller.signal, + }); + await executeLiveWorkflow({ + input, + companyId: miss.companyId, + identity: miss.identity, + existingProfile: null, + researchRunId, + controller, + writer, + researchCache, + }); + return; + } + } + + if (action === "select" && selectedSnapshot) { + const traceContext: ResearchTraceContext = { + researchRunId, + companyId: selectedSnapshot.profile.id, + requestedSources: [], + cacheHit: true, + cacheMatchedBy: "user_selection", + cacheAction: "select", + }; + await traceResearch(traceContext, async (traceId) => { + await emitResearchScores(traceId, { + sourceResults: [], + hasProfile: true, + hasAnalysis: true, + overallConfidence: selectedSnapshot.profile.overallConfidence, + outcome: "complete", + }); }); - } finally { - clearTimeout(deadlineTimeout); - req.signal.removeEventListener("abort", onReqAbort); - await flushLangfuse(); - writer.close(); + + writer.write({ + event: "cache:hit", + data: { + companyId: selectedSnapshot.profile.id, + matchedBy: "user_selection", + version: selectedSnapshot.profile.version, + lastSyncedAt: selectedSnapshot.lastSyncedAt, + }, + } as StreamEvent); + writer.write({ + event: "profile:ready", + data: { profile: selectedSnapshot.profile }, + } as StreamEvent); + if (selectedSnapshot.diff) { + writer.write({ + event: "diff:ready", + data: { diff: selectedSnapshot.diff }, + } as StreamEvent); + } + writer.write({ + event: "analysis:ready", + data: { report: selectedSnapshot.report }, + } as StreamEvent); + writer.write({ event: "done", data: {} } as StreamEvent); + return; } - })(); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - } catch (err) { - return new Response( - JSON.stringify({ - error: err instanceof Error ? err.message : "Invalid request", - }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); + + if (action === "refresh" && refreshSnapshot) { + const identity = normalizeCompanyIdentity(input); + await executeLiveWorkflow({ + input, + companyId: refreshCompanyId!, + identity, + existingProfile: refreshSnapshot.profile, + researchRunId, + controller, + writer, + researchCache, + }); + return; + } + + if (action === "bypass") { + const miss = await researchCache.resolveMiss(input, { + signal: controller.signal, + }); + await executeLiveWorkflow({ + input, + companyId: miss.companyId, + identity: miss.identity, + existingProfile: null, + researchRunId, + controller, + writer, + researchCache, + }); + return; + } + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + writer.write({ + event: "error", + data: { message }, + } as StreamEvent); + writer.write({ event: "done", data: {} } as StreamEvent); + } finally { + clearTimeout(deadlineTimeout); + req.signal.removeEventListener("abort", onReqAbort); + await flushLangfuse(); + writer.close(); + } + })(); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} + +async function executeLiveWorkflow({ + input, + companyId, + identity, + existingProfile, + researchRunId, + controller, + writer, + researchCache, +}: { + input: CompanyInput; + companyId: string; + identity: NormalizedCompanyIdentity; + existingProfile: CompanyProfile | null; + researchRunId: string; + controller: AbortController; + writer: SSEWriter; + researchCache: ResearchCache; +}) { + const guards = getGuards(); + const llm = createLLMAdapter(); + const search = createSearchAdapter(); + const scraper = createScraperAdapter(); + const registry = createRegistryAdapter(); + + const profile = createProfileModule({ llm }); + const analyst = createAnalystModule({ llm }); + + const workflow = createResearchWorkflow({ + search, + scraper, + registry, + profile, + analyst, + guards, + }); + + const traceContext: ResearchTraceContext = { + researchRunId, + companyId, + requestedSources: [ + "web_search", + "website", + "news", + "registry", + ...(input.linkedinUrl ? [("linkedin" as SourceName)] : []), + ], + cacheHit: false, + cacheMatchedBy: "none", + cacheAction: existingProfile ? "refresh" : "auto", + }; + const langfuseCallback = createLangfuseCallback(traceContext); + + let finalState: ResearchWorkflowState | null = null; + + await traceResearch(traceContext, async (traceId) => { + try { + for await (const event of workflow.stream(input, { + researchRunId, + companyId, + existingProfile, + signal: controller.signal, + callbacks: langfuseCallback ? [langfuseCallback] : undefined, + onComplete: async (state) => { + finalState = state; + updateResearchTraceOutcome(state); + await emitResearchScores(traceId, { + sourceResults: state.sourceResults, + hasProfile: Boolean(state.profile), + hasAnalysis: Boolean(state.report), + overallConfidence: state.profile?.overallConfidence ?? 0, + outcome: state.outcome === "running" ? "partial" : state.outcome, + }); + }, + })) { + writer.write(event); + } + } catch (err) { + if ((err as Error).name === "AbortError") { + updateResearchObservationOutcome("cancelled"); + return; + } + updateResearchObservationOutcome("failed"); + await emitResearchScores(traceId, { + sourceResults: [], + hasProfile: false, + hasAnalysis: false, + overallConfidence: 0, + outcome: "failed", + }); + throw err; + } + }); + + // Post-workflow atomic cache persistence and event emission + if (finalState) { + const s = finalState as ResearchWorkflowState; + if (s.profile && s.report) { + try { + await researchCache.persist( + identity, + { + profile: s.profile, + report: s.report, + diff: s.diff ?? null, + }, + { signal: controller.signal } + ); + } catch (persistErr) { + console.error("Failed to persist research snapshot:", persistErr); + writer.write({ + event: "error", + data: { + message: "Không thể lưu kết quả nghiên cứu vào bộ nhớ đệm.", + code: "persist_failed", + }, + } as StreamEvent); + } + + writer.write({ + event: "profile:ready", + data: { profile: s.profile }, + } as StreamEvent); + if (s.diff) { + writer.write({ + event: "diff:ready", + data: { diff: s.diff }, + } as StreamEvent); + } + writer.write({ + event: "analysis:ready", + data: { report: s.report }, + } as StreamEvent); + } } + + writer.write({ event: "done", data: {} } as StreamEvent); } diff --git a/src/app/components/cache-suggestions.tsx b/src/app/components/cache-suggestions.tsx new file mode 100644 index 0000000..b2b1d22 --- /dev/null +++ b/src/app/components/cache-suggestions.tsx @@ -0,0 +1,88 @@ +"use client"; + +import type { CacheSuggestion } from "@/lib/types"; + +export interface CacheSuggestionsProps { + suggestions: CacheSuggestion[]; + onSelect: (companyId: string) => void; + onBypass: () => void; +} + +export function CacheSuggestions({ + suggestions, + onSelect, + onBypass, +}: CacheSuggestionsProps) { + if (!suggestions || suggestions.length === 0) return null; + + return ( +
+
+
+

+ Tìm thấy kết quả trong bộ nhớ đệm +

+

+ Chọn một hồ sơ có sẵn để tải ngay dữ liệu hoặc tiếp tục nghiên cứu mới + toàn diện. +

+
+
+ +
+ {suggestions.map((suggestion) => ( + + ))} +
+ +
+ +
+
+ ); +} diff --git a/src/app/components/profile-card.tsx b/src/app/components/profile-card.tsx index 759babf..9a2d990 100644 --- a/src/app/components/profile-card.tsx +++ b/src/app/components/profile-card.tsx @@ -137,10 +137,18 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) { {/* Criteria Breakdown (5 Core Criteria) */}
{report.fitScore.criteria.map((c) => ( - @@ -171,9 +179,18 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) {

Nhận định chuyên gia (Executive Summary)

-

- {report.executiveSummary} -

+ +
+ {report.executiveSummary} + Kiểm chứng ↗ +
+
)} @@ -187,21 +204,30 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) {

{report.riskFlags.map((rf, i) => ( -
- - [{rf.type}] {rf.severity} - - {rf.description} -
+
+ + [{rf.type}] {rf.severity} + + + Xem chứng cứ ↗ + +
+ {rf.description} + ))}
@@ -215,24 +241,35 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) {

{report.suggestedActions.map((sa, i) => ( -
-
- - {sa.priority} +
+
+ + {sa.priority} + + + {sa.action} + +
+ + Tìm kiếm ↗ - {sa.action}

{sa.reasoning}

-
+ ))}
@@ -243,9 +280,22 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) { {/* Description */}
-

- {profile.description} -

+ +
+

+ {profile.description} +

+ + Kiểm chứng ↗ + +
+
{/* Key Info Grid */} @@ -253,28 +303,49 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) { {profile.website && ( - {profile.website.replace(/^https?:\/\//, "")} - - } + href={profile.website} + title="Nhấn để mở website chính thức" + value={profile.website.replace(/^https?:\/\//, "")} + /> + )} + {profile.taxId && ( + )} - {profile.taxId && } {profile.foundedYear && ( - + )} {profile.companySize && ( - + )} {profile.headquarters && (
{profile.keyPeople.map((person, i) => ( -
{person.name.charAt(0)}
-
-

{person.name}

+
+

+ {person.name} + + ↗ + +

{person.title}

-
+ ))}
@@ -313,12 +393,17 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) {
{profile.products.map((p) => ( - - {p} - + {p} + + ))}
@@ -329,12 +414,17 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) {
{profile.markets.map((m) => ( - - {m} - + {m} + + ))}
@@ -345,10 +435,22 @@ export function ProfileCard({ profile, diff, report }: ProfileCardProps) {
{profile.recentActivities.slice(0, 5).map((act, i) => ( - + ))}
@@ -443,12 +545,36 @@ function Section({ function InfoItem({ label, value, + href, + title, }: { label: string; value: React.ReactNode; + href?: string; + title?: string; }) { + if (href) { + return ( + +
+

{label}

+ + ↗ + +
+

{value}

+
+ ); + } + return ( -
+

{label}

{value}

diff --git a/src/app/components/research-progress.tsx b/src/app/components/research-progress.tsx index f92117a..27e216c 100644 --- a/src/app/components/research-progress.tsx +++ b/src/app/components/research-progress.tsx @@ -13,7 +13,7 @@ const SOURCE_LABELS: Record = { interface ResearchProgressProps { sourceStatuses: Record; - findings: { source: SourceName; summary: string }[]; + findings: { source: SourceName; summary: string; url?: string }[]; status: string; } @@ -73,19 +73,37 @@ export function ResearchProgress({ {/* Findings log */} {findings.length > 0 && (
- - Xem {findings.length} phát hiện chi tiết + + Xem {findings.length} phát hiện chi tiết (Nhấn để mở nguồn kiểm chứng) -
- {findings.map((f, i) => ( -
- [{f.source}]{" "} - {f.summary.slice(0, 120)}... -
- ))} +
+ {findings.map((f, i) => { + const targetUrl = + f.url || + `https://www.google.com/search?q=${encodeURIComponent(f.summary.slice(0, 80))}`; + return ( + +
+ + [{SOURCE_LABELS[f.source]?.label ?? f.source}] + + + Mở nguồn ↗ + +
+

+ {f.summary} +

+
+ ); + })}
)} diff --git a/src/app/hooks/use-research.ts b/src/app/hooks/use-research.ts index 1aa912c..ef44006 100644 --- a/src/app/hooks/use-research.ts +++ b/src/app/hooks/use-research.ts @@ -1,28 +1,41 @@ "use client"; -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; import type { CompanyInput, CompanyProfile, ProfileDiff, AnalysisReport, SourceName, + StreamEvent, + CacheSuggestion, + CacheHitMatchedBy, + ResearchErrorCode, + ResearchRequest, } from "@/lib/types"; export type SourceStatus = "idle" | "started" | "done" | "failed"; export interface ResearchState { - status: "idle" | "researching" | "building" | "done" | "error"; + status: "idle" | "researching" | "building" | "suggesting" | "done" | "error"; input: CompanyInput | null; sourceStatuses: Record; - findings: { source: SourceName; summary: string }[]; + findings: { source: SourceName; summary: string; url?: string }[]; profile: CompanyProfile | null; diff: ProfileDiff | null; report: AnalysisReport | null; error: string | null; + errorCode?: ResearchErrorCode; + notice?: string | null; + suggestions: CacheSuggestion[]; + cacheHit: { + matchedBy: CacheHitMatchedBy; + version: number; + lastSyncedAt: string; + } | null; } -const INITIAL_STATE: ResearchState = { +export const INITIAL_STATE: ResearchState = { status: "idle", input: null, sourceStatuses: { @@ -37,155 +50,252 @@ const INITIAL_STATE: ResearchState = { diff: null, report: null, error: null, + notice: null, + suggestions: [], + cacheHit: null, }; -export function useResearch() { - const [state, setState] = useState(INITIAL_STATE); - const abortRef = useRef(null); - - const research = useCallback(async (input: CompanyInput) => { - // Abort previous research - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - - setState({ - ...INITIAL_STATE, - input, - status: "researching", - }); - - try { - const response = await fetch("/api/research", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - signal: controller.signal, - }); - - if (!response.ok) { - const errBody = await response.json().catch(() => ({})); - throw new Error( - (errBody as { error?: string }).error ?? `HTTP ${response.status}` - ); - } - - const reader = response.body?.getReader(); - if (!reader) throw new Error("No response stream"); - - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - let currentEvent = ""; - for (const line of lines) { - if (line.startsWith("event: ")) { - currentEvent = line.slice(7).trim(); - } else if (line.startsWith("data: ") && currentEvent) { - try { - const data = JSON.parse(line.slice(6)); - handleSSEEvent(currentEvent, data, setState); - } catch { - // Skip malformed JSON - } - currentEvent = ""; - } - } - } - } catch (err) { - if ((err as Error).name === "AbortError") return; - setState((prev) => ({ - ...prev, - status: "error", - error: (err as Error).message, - })); - } - }, []); - - const reset = useCallback(() => { - abortRef.current?.abort(); - setState(INITIAL_STATE); - }, []); - - return { state, research, reset }; +export function buildResearchRequest( + input: CompanyInput, + cache?: ResearchRequest["cache"] +): ResearchRequest { + return cache ? { input, cache } : { input }; } -function handleSSEEvent( - event: string, - data: Record, - setState: React.Dispatch> -) { - switch (event) { +export function reduceResearchEvent( + state: ResearchState, + event: StreamEvent +): ResearchState { + switch (event.event) { + case "research:start": + return { + ...state, + status: "researching", + error: null, + errorCode: undefined, + sourceStatuses: { + web_search: "idle", + website: "idle", + registry: "idle", + news: "idle", + linkedin: "idle", + }, + }; + case "research:progress": - setState((prev) => ({ - ...prev, + return { + ...state, sourceStatuses: { - ...prev.sourceStatuses, - [data.source as string]: data.status as SourceStatus, + ...state.sourceStatuses, + [event.data.source]: event.data.status as SourceStatus, }, - })); - break; + }; case "research:finding": - setState((prev) => ({ - ...prev, + return { + ...state, findings: [ - ...prev.findings, + ...state.findings, { - source: data.source as SourceName, - summary: data.summary as string, + source: event.data.source, + summary: event.data.summary, + url: event.data.url, }, ], - })); - break; + }; case "profile:building": - setState((prev) => ({ - ...prev, + return { + ...state, status: "building", - })); - break; + }; case "profile:ready": - setState((prev) => ({ - ...prev, - profile: data.profile as CompanyProfile, - })); - break; + return { + ...state, + profile: event.data.profile, + }; case "diff:ready": - setState((prev) => ({ - ...prev, - diff: (data.diff as ProfileDiff) ?? null, - })); - break; + return { + ...state, + diff: event.data.diff, + }; case "analysis:ready": - setState((prev) => ({ - ...prev, - report: (data.report as AnalysisReport) ?? null, - })); - break; + return { + ...state, + report: event.data.report, + }; + + case "cache:hit": + return { + ...state, + cacheHit: { + matchedBy: event.data.matchedBy, + version: event.data.version, + lastSyncedAt: event.data.lastSyncedAt, + }, + }; + + case "cache:suggestions": + return { + ...state, + status: "suggesting", + suggestions: event.data.suggestions, + }; case "error": - setState((prev) => ({ - ...prev, - error: data.message as string, - })); - break; + if (event.data.code === "cache_invalid") { + return { + ...state, + notice: event.data.message, + }; + } + return { + ...state, + error: event.data.message, + errorCode: event.data.code, + }; case "done": - setState((prev) => ({ - ...prev, - status: prev.error && !prev.profile ? "error" : "done", - })); - break; + if (state.status === "suggesting") { + return state; + } + return { + ...state, + status: state.error && !state.profile ? "error" : "done", + }; + + default: + return state; } } + +export function useResearch() { + const [state, setState] = useState(INITIAL_STATE); + const abortRef = useRef(null); + + const research = useCallback( + async (input: CompanyInput, cache?: ResearchRequest["cache"]) => { + // Abort previous research + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setState({ + ...INITIAL_STATE, + input, + status: "researching", + }); + + try { + const payload = buildResearchRequest(input, cache); + + const response = await fetch("/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + const errBody = (await response.json().catch(() => ({}))) as { + error?: string; + code?: ResearchErrorCode; + }; + setState((prev) => ({ + ...prev, + status: "error", + error: errBody.error ?? `HTTP ${response.status}`, + errorCode: errBody.code, + })); + return; + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response stream"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + let currentEvent = ""; + for (const line of lines) { + if (line.startsWith("event: ")) { + currentEvent = line.slice(7).trim(); + } else if (line.startsWith("data: ") && currentEvent) { + try { + const data = JSON.parse(line.slice(6)); + const streamEvent = { + event: currentEvent, + data, + } as StreamEvent; + setState((prev) => reduceResearchEvent(prev, streamEvent)); + } catch { + // Skip malformed JSON + } + currentEvent = ""; + } + } + } + } catch (err) { + if ((err as Error).name === "AbortError") return; + setState((prev) => ({ + ...prev, + status: "error", + error: (err as Error).message, + })); + } + }, + [] + ); + + const selectSuggestion = useCallback( + (companyId: string) => { + if (!state.input) return; + void research(state.input, { action: "select", companyId }); + }, + [research, state.input] + ); + + const refreshResearch = useCallback(() => { + if (!state.input || !state.profile) return; + void research(state.input, { + action: "refresh", + companyId: state.profile.id, + }); + }, [research, state.input, state.profile]); + + const bypassAndResearch = useCallback(() => { + if (!state.input) return; + void research(state.input, { action: "bypass" }); + }, [research, state.input]); + + const reset = useCallback(() => { + abortRef.current?.abort(); + setState(INITIAL_STATE); + }, []); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + return { + state, + research, + selectSuggestion, + refreshResearch, + bypassAndResearch, + reset, + }; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 31f717b..0f33b61 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,22 @@ "use client"; +import Image from "next/image"; import { ResearchForm } from "./components/research-form"; import { ResearchProgress } from "./components/research-progress"; import { ProfileCard } from "./components/profile-card"; +import { CacheSuggestions } from "./components/cache-suggestions"; import { useResearch } from "./hooks/use-research"; export default function HomePage() { - const { state, research, reset } = useResearch(); + const { + state, + research, + selectSuggestion, + refreshResearch, + bypassAndResearch, + reset, + } = useResearch(); + const isLoading = state.status === "researching" || state.status === "building"; @@ -16,10 +26,12 @@ export default function HomePage() {
- PartnerIQ Logo

PartnerIQ

@@ -59,7 +71,7 @@ export default function HomePage() {
research(input)} isLoading={isLoading} initialInput={state.input} /> @@ -86,18 +98,40 @@ export default function HomePage() { ) : ( /* ─── Research / Results state ─── */
- {/* Left panel: form + progress */} + {/* Left panel: form + progress + suggestions */}
research(input)} isLoading={isLoading} initialInput={state.input} /> - + + {state.status === "suggesting" ? ( + + ) : ( + + )} + + {state.notice && ( +
+
+ ⚠️ +

{state.notice}

+
+
+ )} {state.error && (
@@ -108,7 +142,37 @@ export default function HomePage() {
{/* Right panel: profile */} -
+
+ {state.profile && ( +
+
+ {state.cacheHit ? ( + + Đã tải từ bộ nhớ đệm (v{state.cacheHit.version}) + + ) : ( + + Nghiên cứu trực tiếp mới nhất + + )} + {state.cacheHit && ( + + Đồng bộ: {new Date(state.cacheHit.lastSyncedAt).toLocaleString("vi-VN")} + + )} +
+ + +
+ )} + {state.profile ? ( ; - writer: { - write(event: StreamEvent): void; - close(): void; - }; + writer: SSEWriter; } { const encoder = new TextEncoder(); let controller: ReadableStreamDefaultController; diff --git a/src/lib/types.ts b/src/lib/types.ts index 6ab2504..b246905 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -185,9 +185,220 @@ export interface AnalysisContext { sponsorCriteria?: string; } +// ─── Cache & Request Contracts ─── + +export const CacheActionSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("select"), companyId: z.string().min(1) }).strict(), + z.object({ action: z.literal("refresh"), companyId: z.string().min(1) }).strict(), + z.object({ action: z.literal("bypass") }).strict(), +]); + +export const ResearchRequestSchema = z.object({ + input: CompanyInputSchema, + cache: CacheActionSchema.optional(), +}).strict(); + +export type ResearchRequest = z.infer; + +export type ResearchErrorCode = + | "identity_conflict" + | "cache_invalid" + | "invalid_cache_selection" + | "cache_unavailable" + | "persist_failed" + | "research_failed"; + +export type CacheHitMatchedBy = "tax_id" | "domain" | "selected" | "user_selection"; + +export interface CacheSuggestion { + companyId: string; + officialName: string; + taxId?: string; + domain?: string; + lastSyncedAt: string; +} + +// ─── Runtime Schemas for Cached Snapshot ─── + +export const SourceNameSchema = z.enum([ + "web_search", + "website", + "registry", + "news", + "linkedin", +]); + +export const AddressSchema = z.object({ + street: z.string().optional(), + city: z.string().optional(), + province: z.string().optional(), + country: z.string(), +}); + +export const PersonSchema = z.object({ + name: z.string(), + title: z.string(), + source: SourceNameSchema, + confidence: z.number().min(0).max(1), +}); + +export const ActivitySchema = z.object({ + date: z.coerce.date(), + title: z.string(), + summary: z.string(), + url: z.string(), + source: SourceNameSchema, +}); + +export const SourceCitationSchema = z.object({ + source: SourceNameSchema, + url: z.string(), + accessedAt: z.coerce.date(), + fieldsContributed: z.array(z.string()), +}); + +export const CompanySizeSchema = z.enum([ + "1-10", + "11-50", + "51-200", + "201-500", + "501-1000", + "1000+", +]); + +export const RevenueRangeSchema = z.enum([ + "< 1B VND", + "1-10B VND", + "10-100B VND", + "100B-1T VND", + "> 1T VND", +]); + +export const CompanyProfileSchema = z.object({ + id: z.string(), + version: z.number().int().min(1), + createdAt: z.coerce.date(), + input: CompanyInputSchema, + officialName: z.string(), + tradingNames: z.array(z.string()), + taxId: z.string().optional(), + industry: z.array(z.string()), + description: z.string(), + foundedYear: z.number().int().optional(), + headquarters: AddressSchema.optional(), + website: z.string().optional(), + keyPeople: z.array(PersonSchema), + products: z.array(z.string()), + markets: z.array(z.string()), + companySize: CompanySizeSchema.optional(), + revenue: RevenueRangeSchema.optional(), + recentActivities: z.array(ActivitySchema), + lastUpdated: z.coerce.date(), + sources: z.array(SourceCitationSchema), + overallConfidence: z.number().min(0).max(1), + lowConfidence: z.boolean().optional(), +}); + +export const FieldChangeSchema = z.object({ + field: z.string(), + oldValue: z.unknown(), + newValue: z.unknown(), + changeType: z.enum(["added", "removed", "modified"]), + significance: z.enum(["high", "medium", "low"]), +}); + +export const ProfileDiffSchema = z.object({ + companyId: z.string(), + fromVersion: z.number().int(), + toVersion: z.number().int(), + changes: z.array(FieldChangeSchema), + summary: z.string(), +}); + +export const FitScoreCriteriaSchema = z.object({ + name: z.string(), + score: z.number().min(0).max(100), + weight: z.number(), + reasoning: z.string().optional(), +}); + +export const FitScoreSchema = z.object({ + score: z.number().min(0).max(100), + reasoning: z.string(), + criteria: z.array(FitScoreCriteriaSchema), +}); + +export const RiskFlagSchema = z.object({ + type: z.enum(["legal", "financial", "reputation", "operational"]), + description: z.string(), + severity: z.enum(["high", "medium", "low"]), + source: SourceNameSchema, +}); + +export const SuggestedActionSchema = z.object({ + action: z.string(), + priority: z.enum(["high", "medium", "low"]), + reasoning: z.string(), +}); + +export const AnalysisReportSchema = z.object({ + companyId: z.string(), + generatedAt: z.coerce.date(), + fitScore: FitScoreSchema.optional(), + riskFlags: z.array(RiskFlagSchema), + suggestedActions: z.array(SuggestedActionSchema), + executiveSummary: z.string(), +}); + +export interface ResearchSnapshot { + profile: CompanyProfile; + report: AnalysisReport; + diff: ProfileDiff | null; + lastSyncedAt: string; +} + +export const ResearchSnapshotSchema = z.object({ + profile: CompanyProfileSchema, + report: AnalysisReportSchema, + diff: ProfileDiffSchema.nullable(), + lastSyncedAt: z.string().datetime(), +}).strict().superRefine((snapshot, ctx) => { + if (snapshot.report.companyId !== snapshot.profile.id) { + ctx.addIssue({ + code: "custom", + path: ["report", "companyId"], + message: "Analysis report companyId must match profile id", + }); + } + if ( + snapshot.diff && + (snapshot.diff.companyId !== snapshot.profile.id || + snapshot.diff.toVersion !== snapshot.profile.version) + ) { + ctx.addIssue({ + code: "custom", + path: ["diff"], + message: "Profile diff must match profile id and version", + }); + } +}); + // ─── SSE Stream Events ─── export type StreamEvent = + | { + event: "cache:hit"; + data: { + companyId: string; + matchedBy: CacheHitMatchedBy; + version: number; + lastSyncedAt: string; + }; + } + | { + event: "cache:suggestions"; + data: { suggestions: CacheSuggestion[] }; + } | { event: "research:start"; data: { sources: SourceName[] } } | { event: "research:progress"; @@ -195,13 +406,20 @@ export type StreamEvent = } | { event: "research:finding"; - data: { source: SourceName; summary: string }; + data: { source: SourceName; summary: string; url?: string }; } | { event: "profile:building"; data: { message: string } } | { event: "profile:ready"; data: { profile: CompanyProfile } } | { event: "diff:ready"; data: { diff: ProfileDiff | null } } | { event: "analysis:ready"; data: { report: AnalysisReport } } - | { event: "error"; data: { message: string; source?: SourceName } } + | { + event: "error"; + data: { + message: string; + source?: SourceName; + code?: ResearchErrorCode; + }; + } | { event: "done"; data: Record }; // ─── Source Result (error contract) ─── @@ -230,3 +448,4 @@ export interface PreparedEvidence { sourceCoverage: number; outcome: Exclude; } + diff --git a/src/modules/cache/index.ts b/src/modules/cache/index.ts new file mode 100644 index 0000000..9a40f75 --- /dev/null +++ b/src/modules/cache/index.ts @@ -0,0 +1,339 @@ +import type { + CompanyInput, + CacheSuggestion, + ResearchSnapshot, +} from "@/lib/types"; +import type { + StorageAdapter, + StorageReadOptions, + StorageWriteOptions, +} from "@/adapters/storage/types"; + +const TAX_ID_PATTERN = /^\d{10}(?:\d{3})?$/; + +export interface NormalizedCompanyIdentity { + taxId: string | null; + domain: string | null; + name: string; +} + +export interface IdentityCandidate { + companyId: string; + taxId: string | null; + domain: string | null; + name: string; +} + +export type CacheDecision = + | { kind: "hit"; companyId: string; matchedBy: "tax_id" | "domain" } + | { kind: "suggestions"; companyIds: string[] } + | { kind: "miss" } + | { kind: "conflict"; taxCompanyId: string; domainCompanyIds: string[] }; + +export type CacheResolution = + | { + kind: "hit"; + snapshot: ResearchSnapshot; + matchedBy: "tax_id" | "domain"; + } + | { kind: "suggestions"; suggestions: CacheSuggestion[] } + | { + kind: "miss"; + identity: NormalizedCompanyIdentity; + cacheInvalid: boolean; + } + | { + kind: "conflict"; + taxCompanyId: string; + domainCompanyIds: string[]; + }; + +export interface ResearchCache { + lookup(input: CompanyInput, options?: StorageReadOptions): Promise; + select( + input: CompanyInput, + companyId: string, + options?: StorageReadOptions, + ): Promise; + prepareRefresh( + input: CompanyInput, + companyId: string, + options?: StorageReadOptions, + ): Promise; + resolveMiss( + input: CompanyInput, + options?: StorageWriteOptions, + ): Promise<{ companyId: string; identity: NormalizedCompanyIdentity }>; + persist( + identity: NormalizedCompanyIdentity, + snapshot: Omit, + options?: StorageWriteOptions, + ): Promise; +} + +export class IdentityConflictError extends Error { + readonly code = "identity_conflict"; + constructor(message = "Thông tin định danh công ty mâu thuẫn.") { + super(message); + this.name = "IdentityConflictError"; + } +} + +export class CacheInvalidError extends Error { + readonly code = "cache_invalid"; + constructor(message = "Dữ liệu cache không hợp lệ.") { + super(message); + this.name = "CacheInvalidError"; + } +} + +export class InvalidCacheSelectionError extends Error { + readonly code = "invalid_cache_selection"; + constructor(message = "Lựa chọn cache không hợp lệ với dữ liệu nhập.") { + super(message); + this.name = "InvalidCacheSelectionError"; + } +} + +export class CacheUnavailableError extends Error { + readonly code = "cache_unavailable"; + constructor(message = "Dịch vụ cache tạm thời không khả dụng.") { + super(message); + this.name = "CacheUnavailableError"; + } +} + +export function normalizeTaxId(value?: string): string | null { + if (!value) return null; + const normalized = value.trim().replace(/[\s.-]/g, ""); + if (!TAX_ID_PATTERN.test(normalized)) { + throw new Error("Mã số thuế phải có 10 hoặc 13 chữ số"); + } + return normalized; +} + +export function normalizeDomain(website?: string): string | null { + if (!website) return null; + try { + return new URL(website).hostname + .toLowerCase() + .replace(/\.$/, "") + .replace(/^www\./, ""); + } catch { + return null; + } +} + +export function normalizeName(name: string): string { + return name + .normalize("NFKC") + .trim() + .toLocaleLowerCase("vi-VN") + .replace(/\s+/g, " "); +} + +export function normalizeCompanyIdentity(input: CompanyInput): NormalizedCompanyIdentity { + return { + taxId: normalizeTaxId(input.taxId), + domain: normalizeDomain(input.website), + name: normalizeName(input.name), + }; +} + +export function decideCacheLookup( + identity: NormalizedCompanyIdentity, + candidates: readonly IdentityCandidate[] +): CacheDecision { + const taxMatches = identity.taxId + ? candidates.filter((c) => c.taxId === identity.taxId) + : []; + + const domainMatches = identity.domain + ? candidates.filter((c) => c.domain === identity.domain) + : []; + + // 1. Tax ID lookup + if (identity.taxId && taxMatches.length > 0) { + const taxCompanyId = taxMatches[0].companyId; + if (identity.domain && domainMatches.length > 0) { + const domainCompanyIds = Array.from( + new Set(domainMatches.map((c) => c.companyId)) + ).sort(); + if (!domainCompanyIds.includes(taxCompanyId)) { + return { + kind: "conflict", + taxCompanyId, + domainCompanyIds, + }; + } + } + return { + kind: "hit", + companyId: taxCompanyId, + matchedBy: "tax_id", + }; + } + + // 2. Domain lookup + if (identity.domain && domainMatches.length > 0) { + const domainCompanyIds = Array.from( + new Set(domainMatches.map((c) => c.companyId)) + ).sort(); + if (domainCompanyIds.length === 1) { + return { + kind: "hit", + companyId: domainCompanyIds[0], + matchedBy: "domain", + }; + } + return { + kind: "suggestions", + companyIds: domainCompanyIds, + }; + } + + // 3. Name lookup + const nameMatches = candidates.filter((c) => c.name === identity.name); + if (nameMatches.length > 0) { + const nameCompanyIds = Array.from( + new Set(nameMatches.map((c) => c.companyId)) + ).sort(); + return { + kind: "suggestions", + companyIds: nameCompanyIds, + }; + } + + // 4. Miss + return { kind: "miss" }; +} + +export function createResearchCache(storage: StorageAdapter): ResearchCache { + return { + async lookup(input: CompanyInput, options?: StorageReadOptions): Promise { + const identity = normalizeCompanyIdentity(input); + const candidates = await storage.findIdentityCandidates(identity, options); + const decision = decideCacheLookup(identity, candidates); + + switch (decision.kind) { + case "hit": { + try { + const snapshot = await storage.getLatestCompleteSnapshot(decision.companyId, options); + if (!snapshot) { + return { kind: "miss", identity, cacheInvalid: false }; + } + return { + kind: "hit", + snapshot, + matchedBy: decision.matchedBy, + }; + } catch (err) { + if (err instanceof CacheInvalidError) { + return { kind: "miss", identity, cacheInvalid: true }; + } + throw err; + } + } + case "suggestions": { + const suggestions: CacheSuggestion[] = []; + for (const id of decision.companyIds) { + try { + const snapshot = await storage.getLatestCompleteSnapshot(id, options); + if (snapshot) { + const candidate = candidates.find((c) => c.companyId === id); + suggestions.push({ + companyId: id, + officialName: snapshot.profile.officialName, + taxId: snapshot.profile.taxId ?? candidate?.taxId ?? undefined, + domain: candidate?.domain ?? (snapshot.profile.website ? normalizeDomain(snapshot.profile.website) ?? undefined : undefined), + lastSyncedAt: snapshot.lastSyncedAt, + }); + } + } catch { + // Ignore corrupt candidates in suggestions + } + } + if (suggestions.length === 0) { + return { kind: "miss", identity, cacheInvalid: false }; + } + return { kind: "suggestions", suggestions }; + } + case "conflict": { + return { + kind: "conflict", + taxCompanyId: decision.taxCompanyId, + domainCompanyIds: decision.domainCompanyIds, + }; + } + case "miss": + default: { + return { kind: "miss", identity, cacheInvalid: false }; + } + } + }, + + async select( + input: CompanyInput, + companyId: string, + options?: StorageReadOptions + ): Promise { + const resolution = await this.lookup(input, options); + if ( + resolution.kind !== "suggestions" || + !resolution.suggestions.some((s) => s.companyId === companyId) + ) { + throw new InvalidCacheSelectionError(); + } + + const snapshot = await storage.getLatestCompleteSnapshot(companyId, options); + if (!snapshot) { + throw new InvalidCacheSelectionError(); + } + return snapshot; + }, + + async prepareRefresh( + input: CompanyInput, + companyId: string, + options?: StorageReadOptions + ): Promise { + const identity = normalizeCompanyIdentity(input); + const candidates = await storage.findIdentityCandidates(identity, options); + const decision = decideCacheLookup(identity, candidates); + + if (decision.kind === "conflict") { + throw new IdentityConflictError(); + } + if (decision.kind === "hit" && decision.companyId !== companyId) { + throw new IdentityConflictError(); + } + if (decision.kind === "suggestions" && !decision.companyIds.includes(companyId)) { + throw new IdentityConflictError(); + } + + const snapshot = await storage.getLatestCompleteSnapshot(companyId, options); + if (!snapshot) { + throw new IdentityConflictError("Không tìm thấy dữ liệu công ty để làm mới."); + } + return snapshot; + }, + + async resolveMiss( + input: CompanyInput, + options?: StorageWriteOptions + ): Promise<{ companyId: string; identity: NormalizedCompanyIdentity }> { + const identity = normalizeCompanyIdentity(input); + const candidateId = crypto.randomUUID(); + const companyId = await storage.resolveOrCreateIdentity(identity, candidateId, options); + return { companyId, identity }; + }, + + async persist( + identity: NormalizedCompanyIdentity, + snapshot: Omit, + options?: StorageWriteOptions + ): Promise { + return await storage.persistResearchSnapshot(identity, snapshot, options); + }, + }; +} diff --git a/src/modules/research/sources/website.ts b/src/modules/research/sources/website.ts index b10861a..fc12037 100644 --- a/src/modules/research/sources/website.ts +++ b/src/modules/research/sources/website.ts @@ -103,7 +103,9 @@ async function discoverWebsite( !url.includes("facebook.com") && !url.includes("linkedin.com") && !url.includes("wikipedia.org") && - !url.includes("youtube.com") + !url.includes("youtube.com") && + !url.includes("masothue.com") && + !url.includes("thongtindoanhnghiep.co") ) { return result.url; } diff --git a/src/modules/workflow/index.ts b/src/modules/workflow/index.ts index 5c32593..8ecbc63 100644 --- a/src/modules/workflow/index.ts +++ b/src/modules/workflow/index.ts @@ -9,17 +9,16 @@ import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import type { Callbacks } from "@langchain/core/callbacks/manager"; import type { CompanyInput, + CompanyProfile, SourceError, SourceExecutionResult, SourceName, StreamEvent, } from "@/lib/types"; -import { slugify } from "@/lib/types"; import type { LLMInvocationContext } from "@/adapters/llm/types"; import type { SearchAdapter } from "@/adapters/search/types"; import type { ScraperAdapter } from "@/adapters/scraper/types"; import type { RegistryAdapter } from "@/adapters/registry/types"; -import type { StorageAdapter } from "@/adapters/storage/types"; import type { ResourceGuards } from "@/config"; import type { ProfileModule } from "@/modules/profile"; import type { AnalystModule } from "@/modules/analyst"; @@ -43,6 +42,8 @@ const SSE_EVENT_NAME = "sse_event"; export interface ResearchWorkflowOptions { researchRunId: string; + companyId?: string; + existingProfile?: CompanyProfile | null; signal?: AbortSignal; callbacks?: readonly unknown[]; sessionId?: string; @@ -53,7 +54,6 @@ export interface ResearchWorkflowDeps { search: SearchAdapter; scraper: ScraperAdapter; registry: RegistryAdapter; - storage: StorageAdapter; profile: ProfileModule; analyst: AnalystModule; guards: ResourceGuards; @@ -97,7 +97,7 @@ export function createResearchWorkflow(deps: ResearchWorkflowDeps): ResearchWork const app = compileResearchGraph(deps, runners, options); const eventStream = app.streamEvents( - createInitialState(input, options.researchRunId), + createInitialState(input, options), { version: "v2", signal: options.signal, @@ -139,8 +139,6 @@ export function createResearchWorkflow(deps: ResearchWorkflowDeps): ResearchWork data: { message: "Không tìm thấy thông tin nào về công ty này." }, } as StreamEvent; } - - yield { event: "done", data: {} } as StreamEvent; }, }; } @@ -154,7 +152,7 @@ async function executeGraph( const app = compileResearchGraph(deps, runners, options); return (await app.invoke( - createInitialState(input, options.researchRunId), + createInitialState(input, options), { signal: options.signal, callbacks: options.callbacks as Callbacks, @@ -179,14 +177,14 @@ function compileResearchGraph( function createInitialState( input: CompanyInput, - researchRunId: string, + options: ResearchWorkflowOptions, ): ResearchWorkflowState { return { - researchRunId, + researchRunId: options.researchRunId, input, sourceResults: [], findings: [], - existingProfile: null, + existingProfile: options.existingProfile ?? null, profile: null, diff: null, report: null, @@ -293,22 +291,6 @@ function buildGraph( outcome: prepared.outcome, }; })) - .addNode("load_existing_profile", tracedNode("profile.load", async (state) => { - if (state.fatalError) return {}; - const companyId = slugify(state.input.name); - try { - const existing = await deps.storage.getLatestProfile(companyId, { signal }); - return { existingProfile: existing }; - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to load existing profile"; - await dispatchCustomEvent(SSE_EVENT_NAME, { - event: "error", - data: { message }, - } as StreamEvent); - return { fatalError: message, outcome: "failed" as const }; - } - })) .addNode("build_profile", tracedNode("profile.build", async (state) => { if (state.fatalError || state.findings.length === 0) return {}; @@ -317,12 +299,13 @@ function buildGraph( data: { message: "Đang tổng hợp hồ sơ công ty..." }, } as StreamEvent); - const companyId = slugify(state.input.name); + const targetCompanyId = + options.companyId || state.existingProfile?.id || options.researchRunId; try { const profile = await deps.profile.buildProfile( state.findings, state.input, - state.existingProfile?.id ?? companyId, + targetCompanyId, state.existingProfile?.version, llmContext, ); @@ -337,41 +320,16 @@ function buildGraph( return { fatalError: message, outcome: "failed" as const }; } })) - .addNode("persist_profile", tracedNode("profile.persist", async (state) => { - if (state.fatalError || !state.profile || signal?.aborted) return {}; - try { - await deps.storage.saveProfile(state.profile, { signal }); - await dispatchCustomEvent(SSE_EVENT_NAME, { - event: "profile:ready", - data: { profile: state.profile }, - } as StreamEvent); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to persist profile"; - await dispatchCustomEvent(SSE_EVENT_NAME, { - event: "error", - data: { message }, - } as StreamEvent); - return { fatalError: message, outcome: "failed" as const }; - } - return {}; - })) - .addNode("build_and_persist_diff", tracedNode("profile.diff", async (state) => { + .addNode("build_diff", tracedNode("profile.diff", async (state) => { if (state.fatalError || !state.profile) return {}; if (state.existingProfile) { try { const diff = deps.profile.diffProfiles(state.profile, state.existingProfile); - if (!signal?.aborted) { - await deps.storage.saveDiff(diff, { signal }); - } - await dispatchCustomEvent(SSE_EVENT_NAME, { - event: "diff:ready", - data: { diff }, - } as StreamEvent); return { diff }; } catch (err) { const message = - err instanceof Error ? err.message : "Failed to persist profile diff"; + err instanceof Error ? err.message : "Failed to build profile diff"; await dispatchCustomEvent(SSE_EVENT_NAME, { event: "error", data: { message }, @@ -379,10 +337,6 @@ function buildGraph( return { fatalError: message, outcome: "failed" as const }; } } else { - await dispatchCustomEvent(SSE_EVENT_NAME, { - event: "diff:ready", - data: { diff: null }, - } as StreamEvent); return { diff: null }; } })) @@ -396,11 +350,6 @@ function buildGraph( llmContext, ); - await dispatchCustomEvent(SSE_EVENT_NAME, { - event: "analysis:ready", - data: { report }, - } as StreamEvent); - return { report }; } catch (err) { const message = err instanceof Error ? err.message : "Không thể phân tích hồ sơ."; @@ -421,11 +370,9 @@ function buildGraph( .addEdge("news", "prepare_evidence") .addEdge("registry", "prepare_evidence") .addEdge("linkedin", "prepare_evidence") - .addEdge("prepare_evidence", "load_existing_profile") - .addEdge("load_existing_profile", "build_profile") - .addEdge("build_profile", "persist_profile") - .addEdge("persist_profile", "build_and_persist_diff") - .addEdge("build_and_persist_diff", "analyze") + .addEdge("prepare_evidence", "build_profile") + .addEdge("build_profile", "build_diff") + .addEdge("build_diff", "analyze") .addEdge("analyze", END); } @@ -486,6 +433,7 @@ async function executeSourceRunner( data: { source: finding.source, summary: finding.content.slice(0, 200), + url: finding.url, }, } as StreamEvent); } diff --git a/src/observability/langfuse.ts b/src/observability/langfuse.ts index 6b0e779..93cf34d 100644 --- a/src/observability/langfuse.ts +++ b/src/observability/langfuse.ts @@ -12,7 +12,9 @@ import { updateActiveObservation, } from "@langfuse/tracing"; import { NodeSDK } from "@opentelemetry/sdk-node"; +import crypto from "node:crypto"; import type { + CacheHitMatchedBy, ResearchOutcome, SourceExecutionResult, SourceName, @@ -41,6 +43,59 @@ export interface ResearchTraceContext { companyId: string; requestedSources: SourceName[]; sessionId?: string; + cacheHit?: boolean; + cacheMatchedBy?: CacheHitMatchedBy | "none"; + cacheAction?: "auto" | "bypass" | "select" | "refresh"; +} + +export function hashCompanyIdentifier(identifier: string): string { + const salt = process.env.LANGFUSE_SALT || "partneriq-telemetry-salt"; + return crypto.createHmac("sha256", salt).update(identifier).digest("hex"); +} + +export function fingerprintCacheKey( + keyType: "tax_id" | "domain", + value: string, + secret: string | undefined = process.env.LANGFUSE_SALT || process.env.CACHE_KEY_HMAC_SECRET +): string | undefined { + if (!secret || !value) return undefined; + return crypto.createHmac("sha256", secret).update(`${keyType}:${value}`).digest("hex"); +} + +export interface ResearchCacheTelemetry { + cacheOutcome: + | "hit" + | "miss" + | "suggestions" + | "refresh" + | "bypass" + | "conflict" + | "invalid"; + matchedBy?: "tax_id" | "domain" | "normalized_name" | "selected" | "user_selection"; + companyId?: string; + version?: number; + lastSyncedAt?: string; + lookupDurationMs?: number; + conflictingCompanyIds?: string[]; + keyType?: "tax_id" | "domain"; + keyFingerprint?: string; +} + +export function updateResearchCacheOutcome( + telemetry: ResearchCacheTelemetry, +): void { + if (!isLangfuseEnabled()) return; + updateActiveObservation({ + output: { + cacheOutcome: telemetry.cacheOutcome, + matchedBy: telemetry.matchedBy, + version: telemetry.version, + lastSyncedAt: telemetry.lastSyncedAt, + lookupDurationMs: telemetry.lookupDurationMs, + keyType: telemetry.keyType, + keyFingerprint: telemetry.keyFingerprint, + }, + }); } export interface DeterministicScore { @@ -180,18 +235,28 @@ export async function traceResearch( let taskPromise: Promise | undefined; try { + const isCacheHit = Boolean(context.cacheHit); + const tags = [ + "workflow:research", + "surface:sse", + isCacheHit ? "cache:hit" : "cache:miss", + ]; return await propagateAttributes( { traceName: "partneriq.research", sessionId: context.sessionId, version: APP_VERSION, - tags: ["workflow:research", "surface:sse"], + tags, environment: process.env.LANGFUSE_TRACING_ENVIRONMENT || "production", metadata: { researchRunId: context.researchRunId, companyId: context.companyId, + companyIdHash: hashCompanyIdentifier(context.companyId), requestedSources: context.requestedSources.join(","), + cacheHit: isCacheHit ? "true" : "false", + cacheMatchedBy: context.cacheMatchedBy || "none", + cacheAction: context.cacheAction || "auto", }, }, () => @@ -316,15 +381,24 @@ export function createLangfuseCallback( } try { + const isCacheHit = Boolean(context.cacheHit); return new CallbackHandler({ sessionId: context.sessionId, version: APP_VERSION, - tags: ["workflow:research", "surface:sse"], + tags: [ + "workflow:research", + "surface:sse", + isCacheHit ? "cache:hit" : "cache:miss", + ], traceMetadata: { researchRunId: context.researchRunId, companyId: context.companyId, + companyIdHash: hashCompanyIdentifier(context.companyId), requestedSources: context.requestedSources, appVersion: APP_VERSION, + cacheHit: isCacheHit, + cacheMatchedBy: context.cacheMatchedBy || "none", + cacheAction: context.cacheAction || "auto", }, }); } catch (err) { diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..6880acd --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,414 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "TechBridgeAI" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud +# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is +# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent. +# auto_expose_new_tables = true + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files, directories, or glob patterns that describe your database. +# Supports paths relative to supabase directory: "./schemas/*.sql", "./database". +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[local_smtp] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./supabase/templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = true +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./schemas" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260826074659_research_cache.sql b/supabase/migrations/20260826074659_research_cache.sql new file mode 100644 index 0000000..7df2c7e --- /dev/null +++ b/supabase/migrations/20260826074659_research_cache.sql @@ -0,0 +1,316 @@ +-- ═══════════════════════════════════════════════════════ +-- PartnerIQ — Research Cache Migration +-- ═══════════════════════════════════════════════════════ + +-- 1. Table for Canonical Company Identities +CREATE TABLE IF NOT EXISTS public.company_identities ( + id TEXT PRIMARY KEY, + tax_id TEXT, + normalized_domain TEXT, + normalized_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT TIMEZONE('utc'::text, NOW()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT TIMEZONE('utc'::text, NOW()) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_company_identities_tax_id + ON public.company_identities (tax_id) + WHERE tax_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_company_identities_domain + ON public.company_identities (normalized_domain); +CREATE INDEX IF NOT EXISTS idx_company_identities_name + ON public.company_identities (normalized_name); + +-- 2. Add analysis_report column and complete profile index +ALTER TABLE public.company_profiles + ADD COLUMN IF NOT EXISTS analysis_report JSONB; + +CREATE INDEX IF NOT EXISTS idx_company_profiles_complete + ON public.company_profiles (id, version DESC) + WHERE analysis_report IS NOT NULL; + +-- 3. Backfill identities from existing distinct company_profiles if any +INSERT INTO public.company_identities (id, tax_id, normalized_domain, normalized_name, created_at, updated_at) +SELECT DISTINCT ON (cp.id) + cp.id, + CASE + WHEN (cp.data->>'taxId') ~ '^\d{10}(\d{3})?$' THEN cp.data->>'taxId' + ELSE NULL + END AS tax_id, + CASE + WHEN cp.data->>'website' IS NOT NULL AND cp.data->>'website' <> '' THEN + regexp_replace( + regexp_replace( + lower(split_part(split_part(split_part(cp.data->>'website', '://', 2), '/', 1), ':', 1)), + '\.$', '' + ), + '^www\.', '' + ) + ELSE NULL + END AS normalized_domain, + lower(trim(regexp_replace(cp.official_name, '\s+', ' ', 'g'))) AS normalized_name, + cp.created_at, + cp.updated_at +FROM public.company_profiles cp +ORDER BY cp.id, cp.version DESC +ON CONFLICT (id) DO NOTHING; + +-- 4. Add foreign key constraints +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_identity_fk' + ) THEN + ALTER TABLE public.company_profiles + ADD CONSTRAINT company_profiles_identity_fk + FOREIGN KEY (id) REFERENCES public.company_identities(id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'company_diffs_identity_fk' + ) THEN + ALTER TABLE public.company_diffs + ADD CONSTRAINT company_diffs_identity_fk + FOREIGN KEY (company_id) REFERENCES public.company_identities(id); + END IF; +END $$; + +-- 5. RLS & Server-Only Privileges +DROP POLICY IF EXISTS "Allow anon read/write company_profiles" ON public.company_profiles; +DROP POLICY IF EXISTS "Allow anon read/write company_diffs" ON public.company_diffs; + +ALTER TABLE public.company_identities ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.company_profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.company_diffs ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON public.company_identities FROM anon, authenticated; +REVOKE ALL ON public.company_profiles FROM anon, authenticated; +REVOKE ALL ON public.company_diffs FROM anon, authenticated; + +GRANT SELECT, INSERT, UPDATE ON public.company_identities TO service_role; +GRANT SELECT, INSERT, UPDATE ON public.company_profiles TO service_role; +GRANT SELECT, INSERT, UPDATE ON public.company_diffs TO service_role; + +-- 6. Read-only Lookup RPC +CREATE OR REPLACE FUNCTION public.lookup_company_identities( + p_tax_id text, + p_domain text, + p_name text +) +RETURNS TABLE ( + id text, + tax_id text, + normalized_domain text, + normalized_name text, + created_at timestamptz, + updated_at timestamptz +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $$ + SELECT DISTINCT + ci.id, + ci.tax_id, + ci.normalized_domain, + ci.normalized_name, + ci.created_at, + ci.updated_at + FROM public.company_identities ci + WHERE (p_tax_id IS NOT NULL AND ci.tax_id = p_tax_id) + OR (p_domain IS NOT NULL AND ci.normalized_domain = p_domain) + OR (p_name IS NOT NULL AND ci.normalized_name = p_name) + ORDER BY ci.id; +$$; + +REVOKE EXECUTE ON FUNCTION public.lookup_company_identities(text, text, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.lookup_company_identities(text, text, text) TO service_role; + +-- 7. Transactional Resolve/Create Identity RPC +CREATE OR REPLACE FUNCTION public.resolve_company_identity( + p_tax_id text, + p_domain text, + p_name text, + p_candidate_id text +) +RETURNS text +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $$ +DECLARE + resolved_id text; + tax_owner_id text; +BEGIN + -- Detect conflict between tax ID and domain if both provided + IF p_tax_id IS NOT NULL AND p_domain IS NOT NULL THEN + SELECT id INTO tax_owner_id + FROM public.company_identities + WHERE tax_id = p_tax_id; + + IF tax_owner_id IS NOT NULL THEN + IF EXISTS ( + SELECT 1 FROM public.company_identities + WHERE normalized_domain = p_domain + AND id <> tax_owner_id + ) AND NOT EXISTS ( + SELECT 1 FROM public.company_identities + WHERE normalized_domain = p_domain + AND id = tax_owner_id + ) THEN + RAISE EXCEPTION 'identity_conflict'; + END IF; + END IF; + END IF; + + IF p_tax_id IS NOT NULL THEN + INSERT INTO public.company_identities ( + id, tax_id, normalized_domain, normalized_name + ) VALUES ( + p_candidate_id, p_tax_id, p_domain, p_name + ) ON CONFLICT (tax_id) WHERE tax_id IS NOT NULL DO NOTHING; + + SELECT id INTO resolved_id + FROM public.company_identities + WHERE tax_id = p_tax_id; + ELSIF p_domain IS NOT NULL THEN + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_domain)); + + SELECT id INTO resolved_id + FROM public.company_identities + WHERE normalized_domain = p_domain + AND normalized_name = p_name + ORDER BY id + LIMIT 1; + + IF resolved_id IS NULL THEN + INSERT INTO public.company_identities ( + id, normalized_domain, normalized_name + ) VALUES ( + p_candidate_id, p_domain, p_name + ) RETURNING id INTO resolved_id; + END IF; + ELSE + INSERT INTO public.company_identities (id, normalized_name) + VALUES (p_candidate_id, p_name) + RETURNING id INTO resolved_id; + END IF; + + RETURN resolved_id; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.resolve_company_identity(text, text, text, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.resolve_company_identity(text, text, text, text) TO service_role; + +-- 8. Atomic Persist Research Snapshot RPC +CREATE OR REPLACE FUNCTION public.persist_research_snapshot( + p_company_id text, + p_tax_id text, + p_domain text, + p_name text, + p_version integer, + p_profile_data jsonb, + p_analysis_report jsonb, + p_diff_data jsonb +) +RETURNS timestamptz +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $$ +DECLARE + v_now timestamptz := timezone('utc'::text, now()); + v_official_name text; + v_diff_id text; + v_from_version integer; + v_to_version integer; +BEGIN + -- 1. Lock the target company_identities row + PERFORM 1 + FROM public.company_identities + WHERE id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'identity_not_found'; + END IF; + + -- 2. Recheck tax_id conflict against other identities + IF p_tax_id IS NOT NULL THEN + IF EXISTS ( + SELECT 1 FROM public.company_identities + WHERE tax_id = p_tax_id AND id <> p_company_id + ) THEN + RAISE EXCEPTION 'identity_conflict'; + END IF; + END IF; + + -- 3. Update target identity metadata + UPDATE public.company_identities + SET + tax_id = COALESCE(p_tax_id, tax_id), + normalized_domain = COALESCE(p_domain, normalized_domain), + normalized_name = COALESCE(p_name, normalized_name), + updated_at = v_now + WHERE id = p_company_id; + + v_official_name := COALESCE(p_profile_data->>'officialName', p_name); + + -- 4. Upsert company_profiles + INSERT INTO public.company_profiles ( + id, + version, + official_name, + data, + analysis_report, + created_at, + updated_at + ) VALUES ( + p_company_id, + p_version, + v_official_name, + p_profile_data, + p_analysis_report, + v_now, + v_now + ) + ON CONFLICT (id, version) DO UPDATE SET + official_name = EXCLUDED.official_name, + data = EXCLUDED.data, + analysis_report = EXCLUDED.analysis_report, + updated_at = EXCLUDED.updated_at; + + -- 5. Upsert diff if provided + IF p_diff_data IS NOT NULL THEN + v_diff_id := COALESCE(p_diff_data->>'id', p_company_id || '-v' || p_version); + v_from_version := (p_diff_data->>'fromVersion')::integer; + v_to_version := (p_diff_data->>'toVersion')::integer; + + INSERT INTO public.company_diffs ( + id, + company_id, + from_version, + to_version, + data, + created_at + ) VALUES ( + v_diff_id, + p_company_id, + v_from_version, + v_to_version, + p_diff_data, + v_now + ) + ON CONFLICT (id) DO UPDATE SET + from_version = EXCLUDED.from_version, + to_version = EXCLUDED.to_version, + data = EXCLUDED.data; + END IF; + + RETURN v_now; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.persist_research_snapshot(text, text, text, text, integer, jsonb, jsonb, jsonb) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.persist_research_snapshot(text, text, text, text, integer, jsonb, jsonb, jsonb) TO service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index ef10ff3..dc5f2d0 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1,54 +1,296 @@ -- ═══════════════════════════════════════════════════════ --- PartnerIQ — Supabase PostgreSQL Schema --- Run this script in the Supabase SQL Editor +-- PartnerIQ — Supabase PostgreSQL Canonical Schema -- ═══════════════════════════════════════════════════════ --- 1. Table for Company Profiles (Multi-versioning) +-- 1. Table for Canonical Company Identities +CREATE TABLE IF NOT EXISTS public.company_identities ( + id TEXT PRIMARY KEY, + tax_id TEXT, + normalized_domain TEXT, + normalized_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT TIMEZONE('utc'::text, NOW()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT TIMEZONE('utc'::text, NOW()) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_company_identities_tax_id + ON public.company_identities (tax_id) + WHERE tax_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_company_identities_domain + ON public.company_identities (normalized_domain); +CREATE INDEX IF NOT EXISTS idx_company_identities_name + ON public.company_identities (normalized_name); + +-- 2. Table for Company Profiles (Multi-versioning) CREATE TABLE IF NOT EXISTS public.company_profiles ( id TEXT NOT NULL, version INT NOT NULL, official_name TEXT NOT NULL, data JSONB NOT NULL, + analysis_report JSONB, created_at TIMESTAMPTZ DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, updated_at TIMESTAMPTZ DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, - PRIMARY KEY (id, version) + PRIMARY KEY (id, version), + CONSTRAINT company_profiles_identity_fk FOREIGN KEY (id) REFERENCES public.company_identities(id) ); --- Index for querying latest version quickly CREATE INDEX IF NOT EXISTS idx_company_profiles_lookup -ON public.company_profiles (id, version DESC); + ON public.company_profiles (id, version DESC); --- Index for ordering by last updated CREATE INDEX IF NOT EXISTS idx_company_profiles_updated -ON public.company_profiles (updated_at DESC); + ON public.company_profiles (updated_at DESC); --- 2. Table for Profile Diffs +CREATE INDEX IF NOT EXISTS idx_company_profiles_complete + ON public.company_profiles (id, version DESC) + WHERE analysis_report IS NOT NULL; + +-- 3. Table for Profile Diffs CREATE TABLE IF NOT EXISTS public.company_diffs ( id TEXT PRIMARY KEY, company_id TEXT NOT NULL, from_version INT NOT NULL, to_version INT NOT NULL, data JSONB NOT NULL, - created_at TIMESTAMPTZ DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL + created_at TIMESTAMPTZ DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, + CONSTRAINT company_diffs_identity_fk FOREIGN KEY (company_id) REFERENCES public.company_identities(id) ); --- Index for fetching diffs by company CREATE INDEX IF NOT EXISTS idx_company_diffs_company -ON public.company_diffs (company_id, created_at DESC); + ON public.company_diffs (company_id, created_at DESC); --- 3. Enable Row Level Security (RLS) - Optional for Public/Service Access +-- 4. Enable Row Level Security (RLS) & Server-Only Privileges +ALTER TABLE public.company_identities ENABLE ROW LEVEL SECURITY; ALTER TABLE public.company_profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE public.company_diffs ENABLE ROW LEVEL SECURITY; --- Allow all operations for anon/service role (Public access for the app) -CREATE POLICY "Allow anon read/write company_profiles" -ON public.company_profiles FOR ALL -TO anon, authenticated, service_role -USING (true) -WITH CHECK (true); - -CREATE POLICY "Allow anon read/write company_diffs" -ON public.company_diffs FOR ALL -TO anon, authenticated, service_role -USING (true) -WITH CHECK (true); +REVOKE ALL ON public.company_identities FROM anon, authenticated; +REVOKE ALL ON public.company_profiles FROM anon, authenticated; +REVOKE ALL ON public.company_diffs FROM anon, authenticated; + +GRANT SELECT, INSERT, UPDATE ON public.company_identities TO service_role; +GRANT SELECT, INSERT, UPDATE ON public.company_profiles TO service_role; +GRANT SELECT, INSERT, UPDATE ON public.company_diffs TO service_role; + +-- 5. Read-only Lookup RPC +CREATE OR REPLACE FUNCTION public.lookup_company_identities( + p_tax_id text, + p_domain text, + p_name text +) +RETURNS TABLE ( + id text, + tax_id text, + normalized_domain text, + normalized_name text, + created_at timestamptz, + updated_at timestamptz +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $$ + SELECT DISTINCT + ci.id, + ci.tax_id, + ci.normalized_domain, + ci.normalized_name, + ci.created_at, + ci.updated_at + FROM public.company_identities ci + WHERE (p_tax_id IS NOT NULL AND ci.tax_id = p_tax_id) + OR (p_domain IS NOT NULL AND ci.normalized_domain = p_domain) + OR (p_name IS NOT NULL AND ci.normalized_name = p_name) + ORDER BY ci.id; +$$; + +REVOKE EXECUTE ON FUNCTION public.lookup_company_identities(text, text, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.lookup_company_identities(text, text, text) TO service_role; + +-- 6. Transactional Resolve/Create Identity RPC +CREATE OR REPLACE FUNCTION public.resolve_company_identity( + p_tax_id text, + p_domain text, + p_name text, + p_candidate_id text +) +RETURNS text +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $$ +DECLARE + resolved_id text; + tax_owner_id text; +BEGIN + -- Detect conflict between tax ID and domain if both provided + IF p_tax_id IS NOT NULL AND p_domain IS NOT NULL THEN + SELECT id INTO tax_owner_id + FROM public.company_identities + WHERE tax_id = p_tax_id; + + IF tax_owner_id IS NOT NULL THEN + IF EXISTS ( + SELECT 1 FROM public.company_identities + WHERE normalized_domain = p_domain + AND id <> tax_owner_id + ) AND NOT EXISTS ( + SELECT 1 FROM public.company_identities + WHERE normalized_domain = p_domain + AND id = tax_owner_id + ) THEN + RAISE EXCEPTION 'identity_conflict'; + END IF; + END IF; + END IF; + + IF p_tax_id IS NOT NULL THEN + INSERT INTO public.company_identities ( + id, tax_id, normalized_domain, normalized_name + ) VALUES ( + p_candidate_id, p_tax_id, p_domain, p_name + ) ON CONFLICT (tax_id) WHERE tax_id IS NOT NULL DO NOTHING; + + SELECT id INTO resolved_id + FROM public.company_identities + WHERE tax_id = p_tax_id; + ELSIF p_domain IS NOT NULL THEN + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_domain)); + + SELECT id INTO resolved_id + FROM public.company_identities + WHERE normalized_domain = p_domain + AND normalized_name = p_name + ORDER BY id + LIMIT 1; + + IF resolved_id IS NULL THEN + INSERT INTO public.company_identities ( + id, normalized_domain, normalized_name + ) VALUES ( + p_candidate_id, p_domain, p_name + ) RETURNING id INTO resolved_id; + END IF; + ELSE + INSERT INTO public.company_identities (id, normalized_name) + VALUES (p_candidate_id, p_name) + RETURNING id INTO resolved_id; + END IF; + + RETURN resolved_id; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.resolve_company_identity(text, text, text, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.resolve_company_identity(text, text, text, text) TO service_role; + +-- 7. Atomic Persist Research Snapshot RPC +CREATE OR REPLACE FUNCTION public.persist_research_snapshot( + p_company_id text, + p_tax_id text, + p_domain text, + p_name text, + p_version integer, + p_profile_data jsonb, + p_analysis_report jsonb, + p_diff_data jsonb +) +RETURNS timestamptz +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $$ +DECLARE + v_now timestamptz := timezone('utc'::text, now()); + v_official_name text; + v_diff_id text; + v_from_version integer; + v_to_version integer; +BEGIN + -- 1. Lock the target company_identities row + PERFORM 1 + FROM public.company_identities + WHERE id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'identity_not_found'; + END IF; + + -- 2. Recheck tax_id conflict against other identities + IF p_tax_id IS NOT NULL THEN + IF EXISTS ( + SELECT 1 FROM public.company_identities + WHERE tax_id = p_tax_id AND id <> p_company_id + ) THEN + RAISE EXCEPTION 'identity_conflict'; + END IF; + END IF; + + -- 3. Update target identity metadata + UPDATE public.company_identities + SET + tax_id = COALESCE(p_tax_id, tax_id), + normalized_domain = COALESCE(p_domain, normalized_domain), + normalized_name = COALESCE(p_name, normalized_name), + updated_at = v_now + WHERE id = p_company_id; + + v_official_name := COALESCE(p_profile_data->>'officialName', p_name); + + -- 4. Upsert company_profiles + INSERT INTO public.company_profiles ( + id, + version, + official_name, + data, + analysis_report, + created_at, + updated_at + ) VALUES ( + p_company_id, + p_version, + v_official_name, + p_profile_data, + p_analysis_report, + v_now, + v_now + ) + ON CONFLICT (id, version) DO UPDATE SET + official_name = EXCLUDED.official_name, + data = EXCLUDED.data, + analysis_report = EXCLUDED.analysis_report, + updated_at = EXCLUDED.updated_at; + + -- 5. Upsert diff if provided + IF p_diff_data IS NOT NULL THEN + v_diff_id := COALESCE(p_diff_data->>'id', p_company_id || '-v' || p_version); + v_from_version := (p_diff_data->>'fromVersion')::integer; + v_to_version := (p_diff_data->>'toVersion')::integer; + + INSERT INTO public.company_diffs ( + id, + company_id, + from_version, + to_version, + data, + created_at + ) VALUES ( + v_diff_id, + p_company_id, + v_from_version, + v_to_version, + p_diff_data, + v_now + ) + ON CONFLICT (id) DO UPDATE SET + from_version = EXCLUDED.from_version, + to_version = EXCLUDED.to_version, + data = EXCLUDED.data; + END IF; + + RETURN v_now; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.persist_research_snapshot(text, text, text, text, integer, jsonb, jsonb, jsonb) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.persist_research_snapshot(text, text, text, text, integer, jsonb, jsonb, jsonb) TO service_role; diff --git a/tests/e2e/workflow-e2e.test.ts b/tests/e2e/workflow-e2e.test.ts index 50b3a8a..a71cb1d 100644 --- a/tests/e2e/workflow-e2e.test.ts +++ b/tests/e2e/workflow-e2e.test.ts @@ -35,7 +35,7 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { resetAdapters(); }); - it("handles full E2E research workflow with SSE stream and versioned updates", async () => { + it("handles full E2E research workflow with SSE stream, caching, and versioned updates", async () => { const storage = createStorageAdapter() as MemoryStorageAdapter; storage.clear(); @@ -79,13 +79,15 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { llm.setResponse("Tổng hợp thông tin", JSON.stringify(v1MockProfile)); llm.setResponse("Phân tích và đánh giá", JSON.stringify(mockAnalysisData)); - // 2. Execute First API Request (Version 1) + // 2. Execute First API Request (Initial Miss -> Live Workflow -> Version 1 persisted) const req1 = new NextRequest("http://localhost:3000/api/research", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: "Vingroup", - website: "https://vingroup.net", + input: { + name: "Vingroup", + website: "https://vingroup.net", + }, }), }); @@ -101,13 +103,21 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { expect(text1).toContain("event: analysis:ready"); expect(text1).toContain("event: done"); - // Verify storage persistence - const savedV1 = await storage.getLatestProfile("vingroup"); + // Find persisted company ID + const candidates = await storage.findIdentityCandidates({ + taxId: null, + domain: "vingroup.net", + name: "vingroup", + }); + expect(candidates.length).toBe(1); + const companyId = candidates[0].companyId; + + const savedV1 = await storage.getLatestCompleteSnapshot(companyId); expect(savedV1).not.toBeNull(); - expect(savedV1?.version).toBe(1); - expect(savedV1?.officialName).toBe("Tập đoàn Vingroup"); + expect(savedV1?.profile.version).toBe(1); + expect(savedV1?.profile.officialName).toBe("Tập đoàn Vingroup"); - // 3. Execute Second API Request (Version 2 - Updated data with diff) + // 3. Execute Second API Request with Refresh (Version 2 - Updated data with diff) const v2MockProfile = { ...v1MockProfile, markets: ["Việt Nam", "Mỹ", "Châu Âu"], @@ -120,8 +130,14 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: "Vingroup", - website: "https://vingroup.net", + input: { + name: "Vingroup", + website: "https://vingroup.net", + }, + cache: { + action: "refresh", + companyId, + }, }), }); @@ -133,15 +149,34 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { expect(text2).toContain("event: done"); // Verify version 2 and diff persistence - const savedV2 = await storage.getLatestProfile("vingroup"); - expect(savedV2?.version).toBe(2); - expect(savedV2?.markets).toContain("Mỹ"); + const savedV2 = await storage.getLatestCompleteSnapshot(companyId); + expect(savedV2?.profile.version).toBe(2); + expect(savedV2?.profile.markets).toContain("Mỹ"); - const diffs = await storage.getDiffs(savedV2!.id); + const diffs = await storage.getDiffs(companyId); expect(diffs.length).toBe(1); expect(diffs[0].fromVersion).toBe(1); expect(diffs[0].toVersion).toBe(2); expect(diffs[0].changes.some((c) => c.field === "markets")).toBe(true); + + // 4. Execute Third API Request (Cache Hit - returns cached snapshot immediately) + const req3 = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "Vingroup", + website: "https://vingroup.net", + }, + }), + }); + + const response3 = await POST(req3); + expect(response3.status).toBe(200); + const text3 = await response3.text(); + expect(text3).toContain("event: cache:hit"); + expect(text3).toContain("event: profile:ready"); + expect(text3).toContain("event: done"); }); it("rejects invalid request inputs with HTTP 400", async () => { @@ -149,7 +184,9 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: "", // empty name + input: { + name: "", // empty name + }, }), }); @@ -171,7 +208,9 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { new NextRequest("http://localhost:3000/api/research", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "EDUZ", website: "https://eduz.vn" }), + body: JSON.stringify({ + input: { name: "EDUZ", website: "https://eduz.vn" }, + }), }), ); @@ -204,7 +243,9 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { const req = new NextRequest("http://localhost:3000/api/research", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "FPT", website: "https://fpt.com.vn" }), + body: JSON.stringify({ + input: { name: "FPT", website: "https://fpt.com.vn" }, + }), signal: controller.signal, }); @@ -219,9 +260,15 @@ describe("E2E Workflow Tests - PartnerIQ Research Pipeline", () => { const text = await response.text(); expect(text).toContain("event: research:start"); - // Profile should not have been saved - const saved = await storage.getLatestProfile("fpt"); - expect(saved).toBeNull(); + // Profile / snapshot should not have been saved + const candidates = await storage.findIdentityCandidates({ + taxId: null, + domain: "fpt.com.vn", + name: "fpt", + }); + if (candidates.length > 0) { + const snapshot = await storage.getLatestCompleteSnapshot(candidates[0].companyId); + expect(snapshot).toBeNull(); + } }); }); - diff --git a/tests/integration/research-workflow.test.ts b/tests/integration/research-workflow.test.ts index 6ef2e92..0acb687 100644 --- a/tests/integration/research-workflow.test.ts +++ b/tests/integration/research-workflow.test.ts @@ -1,8 +1,7 @@ -import { describe, expect, it, beforeEach, vi } from "vitest"; +import { describe, expect, it, beforeEach } from "vitest"; import { createResearchWorkflow } from "@/modules/workflow"; import { createProfileModule } from "@/modules/profile"; import { createAnalystModule } from "@/modules/analyst"; -import { MemoryStorageAdapter } from "@/adapters/storage/memory"; import { MockLLMAdapter, MockSearchAdapter, @@ -12,13 +11,11 @@ import type { RegistryAdapter } from "@/adapters/registry"; import type { SearchOptions } from "@/adapters/search/types"; import type { ResourceGuards } from "@/config"; import type { CompanyInput, StreamEvent } from "@/lib/types"; -import * as langfuseObservability from "@/observability/langfuse"; describe("ResearchWorkflow (LangGraph StateGraph)", () => { let llm: MockLLMAdapter; let search: MockSearchAdapter; let scraper: MockScraperAdapter; - let storage: MemoryStorageAdapter; let registry: RegistryAdapter; let guards: ResourceGuards; @@ -26,7 +23,6 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { llm = new MockLLMAdapter(); search = new MockSearchAdapter(); scraper = new MockScraperAdapter(); - storage = new MemoryStorageAdapter(); registry = { findByTaxId: async () => null, }; @@ -98,7 +94,6 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { search, scraper, registry, - storage, profile: createProfileModule({ llm }), analyst: createAnalystModule({ llm }), guards, @@ -272,171 +267,52 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { ).toBe(true); }); - it("emits a fatal error and withholds profile-ready when persistence fails", async () => { - guards.maxQueriesPerResearch = 2; - guards.maxScrapePagesPerResearch = 1; - search.setResults("FPT", [ - { - title: "FPT", - url: "https://fpt.com.vn/about", - snippet: "FPT company information", - }, - ]); - scraper.extract = async (url: string) => ({ - url, - title: "FPT", - text: "FPT company website content long enough for profile synthesis.", - }); - storage.saveProfile = async () => { - throw new Error("Profile storage unavailable"); - }; - const observationOutcome = vi.spyOn( - langfuseObservability, - "updateResearchObservationOutcome", - ); - - const events: StreamEvent[] = []; - for await (const event of buildWorkflow().stream( - { name: "FPT", website: "https://fpt.com.vn" }, - { researchRunId: "persistence-failure" }, - )) { - events.push(event); - } - - expect(events).toContainEqual({ - event: "error", - data: { message: "Profile storage unavailable" }, - }); - expect(events.some((event) => event.event === "profile:ready")).toBe(false); - expect(observationOutcome).toHaveBeenCalledWith("failed"); - observationOutcome.mockRestore(); - }); - - it("aborts an in-flight profile write when the run is cancelled", async () => { - guards.maxQueriesPerResearch = 2; - guards.maxScrapePagesPerResearch = 1; - search.setResults("FPT", [ - { - title: "FPT", - url: "https://fpt.com.vn/about", - snippet: "FPT company information", - }, - ]); - scraper.extract = async (url: string) => ({ - url, - title: "FPT", - text: "FPT company website content long enough for profile synthesis.", - }); - let markWriteStarted: () => void = () => undefined; - const writeStarted = new Promise((resolve) => { - markWriteStarted = resolve; - }); - let writeAborted = false; - storage.saveProfile = async ( - _profile, - options?: { signal?: AbortSignal }, - ) => { - markWriteStarted(); - await new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, 30); - options?.signal?.addEventListener( - "abort", - () => { - clearTimeout(timeout); - writeAborted = true; - reject(options.signal?.reason); - }, - { once: true }, - ); - }); + it("uses the supplied canonical company ID and previous profile for versioning", async () => { + const existingProfile = { + id: "stable-company-id", + version: 1, + createdAt: new Date(), + lastUpdated: new Date(), + input: { name: "Original Name" }, + officialName: "Công ty Cổ phần FPT", + tradingNames: [], + industry: ["Tech"], + description: "Old description", + keyPeople: [], + products: [], + markets: [], + recentActivities: [], + sources: [], + overallConfidence: 0.9, }; - const controller = new AbortController(); - const events: StreamEvent[] = []; - - const consume = (async () => { - for await (const event of buildWorkflow().stream( - { name: "FPT", website: "https://fpt.com.vn" }, - { researchRunId: "cancel-persistence", signal: controller.signal }, - )) { - events.push(event); - } - })(); - await writeStarted; - controller.abort(); - await consume.catch(() => undefined); - expect(writeAborted).toBe(true); - expect(events.some((event) => event.event === "profile:ready")).toBe(false); - }); - - it("stops before profile synthesis when existing-profile storage fails", async () => { - guards.maxQueriesPerResearch = 2; - guards.maxScrapePagesPerResearch = 1; - search.setResults("FPT", [ + search.setResults("Different Display Name", [ { - title: "FPT", - url: "https://fpt.com.vn/about", - snippet: "FPT company information", + title: "Company", + url: "https://example.com", + snippet: "Different Display Name information for research findings", }, ]); - scraper.extract = async (url: string) => ({ - url, - title: "FPT", - text: "FPT company website content long enough for profile synthesis.", - }); - storage.getLatestProfile = async () => { - throw new Error("Profile storage read unavailable"); - }; - const events: StreamEvent[] = []; - for await (const event of buildWorkflow().stream( - { name: "FPT", website: "https://fpt.com.vn" }, - { researchRunId: "storage-read-failure" }, - )) { - events.push(event); - } - - expect(events).toContainEqual({ - event: "error", - data: { message: "Profile storage read unavailable" }, - }); - expect(events.some((event) => event.event === "profile:building")).toBe(false); - }); - - it("passes the run signal into the existing-profile read", async () => { - guards.maxQueriesPerResearch = 2; - guards.maxScrapePagesPerResearch = 1; - search.setResults("FPT", [ + const state = await buildWorkflow().run( + { name: "Different Display Name" }, { - title: "FPT", - url: "https://fpt.com.vn/about", - snippet: "FPT company information", - }, - ]); - scraper.extract = async (url: string) => ({ - url, - title: "FPT", - text: "FPT company website content long enough for profile synthesis.", - }); - let receivedSignal: AbortSignal | undefined; - storage.getLatestProfile = async ( - _companyId: string, - options?: { signal?: AbortSignal }, - ) => { - receivedSignal = options?.signal; - return null; - }; - const controller = new AbortController(); - - await buildWorkflow().run( - { name: "FPT", website: "https://fpt.com.vn" }, - { researchRunId: "storage-read-signal", signal: controller.signal }, + researchRunId: "canonical-id", + companyId: "stable-company-id", + existingProfile, + } ); - expect(receivedSignal).toBe(controller.signal); + expect(state.profile?.id).toBe("stable-company-id"); + expect(state.profile?.version).toBe(2); + expect(state.diff).toMatchObject({ + companyId: "stable-company-id", + fromVersion: 1, + toVersion: 2, + }); }); - it("treats diff persistence failure as fatal", async () => { + it("streams research progress and findings without emitting final snapshot events", async () => { guards.maxQueriesPerResearch = 2; guards.maxScrapePagesPerResearch = 1; search.setResults("FPT", [ @@ -451,28 +327,21 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { title: "FPT", text: "FPT company website content long enough for profile synthesis.", }); - const workflow = buildWorkflow(); - await workflow.run( - { name: "FPT", website: "https://fpt.com.vn" }, - { researchRunId: "diff-v1" }, - ); - storage.saveDiff = async () => { - throw new Error("Diff storage unavailable"); - }; const events: StreamEvent[] = []; - for await (const event of workflow.stream( + for await (const event of buildWorkflow().stream( { name: "FPT", website: "https://fpt.com.vn" }, - { researchRunId: "diff-v2" }, + { researchRunId: "stream-progress-only" }, )) { events.push(event); } - expect(events).toContainEqual({ - event: "error", - data: { message: "Diff storage unavailable" }, - }); - expect(events.some((event) => event.event === "analysis:ready")).toBe(false); + expect(events.some((e) => e.event === "research:start")).toBe(true); + expect(events.some((e) => e.event === "research:progress")).toBe(true); + expect(events.some((e) => e.event === "profile:ready")).toBe(false); + expect(events.some((e) => e.event === "diff:ready")).toBe(false); + expect(events.some((e) => e.event === "analysis:ready")).toBe(false); + expect(events.some((e) => e.event === "done")).toBe(false); }); it("reports the terminal workflow state to its completion hook", async () => { @@ -555,7 +424,6 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { search, scraper, registry, - storage, profile: profileModule, analyst: analystModule, guards, @@ -580,11 +448,8 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { const startEvent = events.find((e) => e.event === "research:start"); expect(startEvent).toBeDefined(); - const profileReady = events.find((e) => e.event === "profile:ready"); - expect(profileReady).toBeDefined(); - - const doneEvent = events.find((e) => e.event === "done"); - expect(doneEvent).toBeDefined(); + const progressEvent = events.find((e) => e.event === "research:progress"); + expect(progressEvent).toBeDefined(); }); it("handles partial source failure without discarding sibling findings", async () => { @@ -603,7 +468,6 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { search, scraper, registry, - storage, profile: profileModule, analyst: analystModule, guards, @@ -627,12 +491,6 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { e.event === "research:progress" ); expect(progressEvents.some((p) => p.data.status === "failed")).toBe(true); - - const profileReady = events.find((e) => e.event === "profile:ready"); - expect(profileReady).toBeDefined(); - - const doneEvent = events.find((e) => e.event === "done"); - expect(doneEvent).toBeDefined(); }); it("skips linkedin when no linkedinUrl is provided", async () => { @@ -643,7 +501,6 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { search, scraper, registry, - storage, profile: profileModule, analyst: analystModule, guards, @@ -703,7 +560,6 @@ describe("ResearchWorkflow (LangGraph StateGraph)", () => { search, scraper, registry, - storage, profile: profileModule, analyst: analystModule, guards: benchmarkGuards, diff --git a/tests/integration/supabase-cache-concurrency.test.ts b/tests/integration/supabase-cache-concurrency.test.ts new file mode 100644 index 0000000..b7d1dba --- /dev/null +++ b/tests/integration/supabase-cache-concurrency.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { createClient } from "@supabase/supabase-js"; + +const testUrl = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; +const anonKey = process.env.SUPABASE_TEST_ANON_KEY; + +const isLiveDb = Boolean(testUrl && serviceKey); + +describe.skipIf(!isLiveDb)("Supabase Research Cache - Integration & Concurrency", () => { + it("resolves domain-only identity concurrently without duplicate rows", async () => { + const first = createClient(testUrl!, serviceKey!, { auth: { persistSession: false } }); + const second = createClient(testUrl!, serviceKey!, { auth: { persistSession: false } }); + + const domain = `race-${crypto.randomUUID()}.example`; + const name = `race ${crypto.randomUUID()}`; + + const [a, b] = await Promise.all([ + first.rpc("resolve_company_identity", { + p_tax_id: null, + p_domain: domain, + p_name: name, + p_candidate_id: crypto.randomUUID(), + }), + second.rpc("resolve_company_identity", { + p_tax_id: null, + p_domain: domain, + p_name: name, + p_candidate_id: crypto.randomUUID(), + }), + ]); + + expect(a.error).toBeNull(); + expect(b.error).toBeNull(); + expect(a.data).toBe(b.data); + + const { data: rows, error } = await first + .from("company_identities") + .select("id") + .eq("normalized_domain", domain); + + expect(error).toBeNull(); + expect(rows?.length).toBe(1); + }); + + it("rolls back persistence when identity conflict occurs post-pipeline", async () => { + const client = createClient(testUrl!, serviceKey!, { auth: { persistSession: false } }); + const taxId = `0101${Math.floor(100000 + Math.random() * 900000)}`; + + await client.rpc("resolve_company_identity", { + p_tax_id: taxId, + p_domain: `comp-a-${taxId}.vn`, + p_name: "company a", + p_candidate_id: crypto.randomUUID(), + }); + + const companyBId = crypto.randomUUID(); + await client.rpc("resolve_company_identity", { + p_tax_id: null, + p_domain: `comp-b-${taxId}.vn`, + p_name: "company b", + p_candidate_id: companyBId, + }); + + // Try to persist companyB with companyA's tax ID -> should fail & rollback + const { error: persistError } = await client.rpc("persist_research_snapshot", { + p_company_id: companyBId, + p_tax_id: taxId, + p_domain: `comp-b-${taxId}.vn`, + p_name: "company b", + p_version: 1, + p_profile_data: { id: companyBId, version: 1, officialName: "company b" }, + p_analysis_report: { companyId: companyBId, generatedAt: new Date().toISOString() }, + p_diff_data: null, + }); + + expect(persistError).not.toBeNull(); + + // Verify profile was not persisted + const { data: profiles } = await client + .from("company_profiles") + .select("id") + .eq("id", companyBId); + + expect(profiles?.length).toBe(0); + }); + + it("denies access to anon role on company_identities and RPCs", async () => { + if (!anonKey) return; + const anonClient = createClient(testUrl!, anonKey, { auth: { persistSession: false } }); + + const { error: selectError } = await anonClient.from("company_identities").select("*"); + expect(selectError).not.toBeNull(); + + const { error: rpcError } = await anonClient.rpc("lookup_company_identities", { + p_tax_id: null, + p_domain: null, + p_name: "test", + }); + expect(rpcError).not.toBeNull(); + }); +}); diff --git a/tests/unit/adapters.test.ts b/tests/unit/adapters.test.ts index f9eb2bd..15a05b5 100644 --- a/tests/unit/adapters.test.ts +++ b/tests/unit/adapters.test.ts @@ -174,5 +174,119 @@ describe("Adapters Unit Tests", () => { expect(diffs.length).toBe(1); expect(diffs[0].summary).toBe("Updated description"); }); + + it("resolves, persists, and retrieves complete research snapshots", async () => { + const identity = { + taxId: "0101245486", + domain: "vingroup.net", + name: "tập đoàn vingroup", + }; + + const resolvedId = await storage.resolveOrCreateIdentity(identity, "company-a"); + expect(resolvedId).toBe("company-a"); + + const draftSnapshot = { + profile: createDummyProfile("company-a", 1), + report: { + companyId: "company-a", + generatedAt: new Date(), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Summary", + }, + diff: null, + }; + + const saved = await storage.persistResearchSnapshot(identity, draftSnapshot); + expect(saved.profile.id).toBe("company-a"); + expect(saved.lastSyncedAt).toBeDefined(); + + const candidates = await storage.findIdentityCandidates(identity); + expect(candidates).toEqual([ + expect.objectContaining({ companyId: "company-a", taxId: "0101245486" }), + ]); + + const snapshot = await storage.getLatestCompleteSnapshot("company-a"); + expect(snapshot).toMatchObject({ + profile: { id: "company-a", version: 1 }, + report: { companyId: "company-a" }, + diff: null, + }); + }); + + it("handles version 2 snapshot with matching diff", async () => { + const identity = { + taxId: "0101245486", + domain: "vingroup.net", + name: "tập đoàn vingroup", + }; + + await storage.resolveOrCreateIdentity(identity, "company-a"); + + const v1Draft = { + profile: createDummyProfile("company-a", 1), + report: { + companyId: "company-a", + generatedAt: new Date(), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Summary v1", + }, + diff: null, + }; + await storage.persistResearchSnapshot(identity, v1Draft); + + const v2Diff: ProfileDiff = { + companyId: "company-a", + fromVersion: 1, + toVersion: 2, + changes: [{ field: "description", oldValue: "v1", newValue: "v2", changeType: "modified", significance: "medium" }], + summary: "Upgraded to v2", + }; + + const v2Draft = { + profile: createDummyProfile("company-a", 2), + report: { + companyId: "company-a", + generatedAt: new Date(), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Summary v2", + }, + diff: v2Diff, + }; + await storage.persistResearchSnapshot(identity, v2Draft); + + const snapshot = await storage.getLatestCompleteSnapshot("company-a"); + expect(snapshot).toMatchObject({ + profile: { id: "company-a", version: 2 }, + report: { companyId: "company-a", executiveSummary: "Summary v2" }, + diff: { toVersion: 2, summary: "Upgraded to v2" }, + }); + }); + + it("throws IdentityConflictError when tax ID is assigned to another company", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101245486", domain: "vingroup.net", name: "vingroup" }, + "company-a" + ); + + await expect( + storage.persistResearchSnapshot( + { taxId: "0101245486", domain: "other.vn", name: "other" }, + { + profile: createDummyProfile("company-b", 1), + report: { + companyId: "company-b", + generatedAt: new Date(), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Summary", + }, + diff: null, + } + ) + ).rejects.toThrow("Thông tin định danh công ty mâu thuẫn."); + }); }); }); diff --git a/tests/unit/cache-suggestions.test.tsx b/tests/unit/cache-suggestions.test.tsx new file mode 100644 index 0000000..78d6135 --- /dev/null +++ b/tests/unit/cache-suggestions.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import React from "react"; +import { renderToString } from "react-dom/server"; +import { CacheSuggestions } from "@/app/components/cache-suggestions"; +import type { CacheSuggestion } from "@/lib/types"; + +describe("CacheSuggestions Component", () => { + const mockSuggestions: CacheSuggestion[] = [ + { + companyId: "comp-fpt", + officialName: "Công ty Cổ phần FPT", + taxId: "0101248141", + domain: "fpt.com.vn", + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }, + { + companyId: "comp-vin", + officialName: "Tập đoàn Vingroup", + taxId: "0101245486", + domain: "vingroup.net", + lastSyncedAt: "2026-08-25T12:00:00.000Z", + }, + ]; + + it("renders suggestions list with names, tax IDs, and domains", () => { + const onSelect = vi.fn(); + const onBypass = vi.fn(); + + const html = renderToString( + + ); + + expect(html).toContain("Công ty Cổ phần FPT"); + expect(html).toContain("0101248141"); + expect(html).toContain("fpt.com.vn"); + expect(html).toContain("Tập đoàn Vingroup"); + expect(html).toContain("0101245486"); + expect(html).toContain("vingroup.net"); + expect(html).toContain("Nghiên cứu mới (Bỏ qua cache)"); + }); + + it("renders null when suggestions list is empty", () => { + const html = renderToString( + + ); + + expect(html).toBe(""); + }); +}); diff --git a/tests/unit/langfuse-observability.test.ts b/tests/unit/langfuse-observability.test.ts index 4915716..dc51a82 100644 --- a/tests/unit/langfuse-observability.test.ts +++ b/tests/unit/langfuse-observability.test.ts @@ -51,6 +51,9 @@ import { flushLangfuse, initOpenTelemetry, traceResearch, + hashCompanyIdentifier, + fingerprintCacheKey, + updateResearchCacheOutcome, } from "@/observability/langfuse"; import type { SourceExecutionResult } from "@/lib/types"; import * as langfuseObservability from "@/observability/langfuse"; @@ -294,4 +297,84 @@ describe("Langfuse Observability & Privacy Minimization", () => { ]); expect(clientMocks.flush).toHaveBeenCalledOnce(); }); + + it("hashes company identifier deterministically with salt", () => { + vi.stubEnv("LANGFUSE_SALT", "test-secret-salt"); + const hash1 = hashCompanyIdentifier("0101248141"); + const hash2 = hashCompanyIdentifier("0101248141"); + const hashDiff = hashCompanyIdentifier("0101245486"); + + expect(hash1).toBe(hash2); + expect(hash1).not.toBe(hashDiff); + expect(hash1).toHaveLength(64); // SHA-256 hex length + }); + + it("fingerprints low-entropy tax IDs with a keyed HMAC", () => { + const first = fingerprintCacheKey("tax_id", "0101248141", "secret-a"); + const second = fingerprintCacheKey("tax_id", "0101248141", "secret-b"); + + expect(first).toMatch(/^[a-f0-9]{64}$/); + expect(first).not.toContain("0101248141"); + expect(second).not.toBe(first); + expect(fingerprintCacheKey("tax_id", "0101248141", undefined)).toBeUndefined(); + }); + + it("updates active observation with research cache telemetry", () => { + vi.stubEnv("LANGFUSE_ENABLED", "true"); + vi.stubEnv("LANGFUSE_PUBLIC_KEY", "pk-test"); + vi.stubEnv("LANGFUSE_SECRET_KEY", "sk-test"); + + updateResearchCacheOutcome({ + cacheOutcome: "hit", + matchedBy: "tax_id", + version: 1, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + lookupDurationMs: 42, + keyType: "tax_id", + keyFingerprint: "fingerprint-123", + }); + + expect(tracingMocks.updateActiveObservation).toHaveBeenCalledWith({ + output: { + cacheOutcome: "hit", + matchedBy: "tax_id", + version: 1, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + lookupDurationMs: 42, + keyType: "tax_id", + keyFingerprint: "fingerprint-123", + }, + }); + }); + + it("includes cache metadata in trace attributes", async () => { + vi.stubEnv("LANGFUSE_ENABLED", "true"); + vi.stubEnv("LANGFUSE_PUBLIC_KEY", "pk-test"); + vi.stubEnv("LANGFUSE_SECRET_KEY", "sk-test"); + + await traceResearch( + { + researchRunId: "run-cache-hit", + companyId: "comp-fpt", + requestedSources: [], + cacheHit: true, + cacheMatchedBy: "tax_id", + cacheAction: "auto", + }, + async () => undefined + ); + + expect(tracingMocks.propagateAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.arrayContaining(["cache:hit"]), + metadata: expect.objectContaining({ + cacheHit: "true", + cacheMatchedBy: "tax_id", + cacheAction: "auto", + companyIdHash: expect.any(String), + }), + }), + expect.any(Function) + ); + }); }); diff --git a/tests/unit/research-cache-route.test.ts b/tests/unit/research-cache-route.test.ts new file mode 100644 index 0000000..6da423b --- /dev/null +++ b/tests/unit/research-cache-route.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "@/app/api/research/route"; +import { MemoryStorageAdapter } from "@/adapters/storage/memory"; +import { CacheInvalidError } from "@/modules/cache"; +import type { CompanyProfile, AnalysisReport } from "@/lib/types"; + +const mockLLM = vi.fn(); +const mockSearch = vi.fn(); +const mockScraper = vi.fn(); +const mockRegistry = vi.fn(); +let storage: MemoryStorageAdapter; + +vi.mock("@/config", () => ({ + createLLMAdapter: () => { + mockLLM(); + return {}; + }, + createSearchAdapter: () => { + mockSearch(); + return {}; + }, + createScraperAdapter: () => { + mockScraper(); + return {}; + }, + createRegistryAdapter: () => { + mockRegistry(); + return {}; + }, + createStorageAdapter: () => storage, + getGuards: () => ({ + maxConcurrentResearch: 1, + maxQueriesPerResearch: 6, + maxConcurrentSourceNodes: 4, + maxConcurrentProviderCalls: 4, + sourceTimeoutMs: 5000, + maxRetriesPerSource: 2, + maxTokensPerResearch: 50000, + maxLLMCallsPerResearch: 10, + scraperDelayMs: 0, + maxScrapePagesPerResearch: 5, + maxResearchPerDay: 50, + maxTokensPerDay: 500000, + }), +})); + +vi.mock("@/modules/profile", () => ({ + createProfileModule: () => ({ + buildProfile: vi.fn(), + diffProfiles: vi.fn(), + }), +})); + +vi.mock("@/modules/analyst", () => ({ + createAnalystModule: () => ({ + analyze: vi.fn(), + }), +})); + +vi.mock("@/modules/workflow", () => ({ + createResearchWorkflow: () => ({ + stream: async function* () { + yield { event: "research:start", data: { sources: [] } }; + }, + }), +})); + +vi.mock("@/observability/langfuse", () => ({ + createLangfuseCallback: () => null, + emitResearchScores: vi.fn(async () => undefined), + flushLangfuse: vi.fn(async () => undefined), + traceResearch: async (_context: unknown, task: (traceId: string) => Promise) => + task("mock-trace-id"), + updateResearchObservationOutcome: vi.fn(), + updateResearchTraceOutcome: vi.fn(), + updateResearchCacheOutcome: vi.fn(), +})); + +describe("API Route - /api/research Cache Read-Through", () => { + const dummyProfile: CompanyProfile = { + id: "comp-fpt", + version: 1, + createdAt: new Date("2026-08-26T00:00:00.000Z"), + lastUpdated: new Date("2026-08-26T08:00:00.000Z"), + input: { name: "FPT Corporation" }, + officialName: "Công ty Cổ phần FPT", + tradingNames: ["FPT"], + taxId: "0101248141", + industry: ["Technology"], + description: "Technology Corporation", + keyPeople: [], + products: [], + markets: [], + recentActivities: [], + sources: [], + overallConfidence: 0.95, + }; + + const dummyReport: AnalysisReport = { + companyId: "comp-fpt", + generatedAt: new Date("2026-08-26T08:00:00.000Z"), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Executive Summary", + }; + + beforeEach(() => { + vi.clearAllMocks(); + storage = new MemoryStorageAdapter(); + }); + + it("returns cache:hit and final events without calling search/LLM providers on exact tax-ID match", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "comp-fpt" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: dummyProfile, report: dummyReport, diff: null } + ); + + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "FPT", + taxId: "0101248141", + }, + }), + }); + + const response = await POST(req); + const body = await response.text(); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockLLM).not.toHaveBeenCalled(); + expect(body).toContain("event: cache:hit"); + expect(body).toContain("event: profile:ready"); + expect(body).toContain("event: analysis:ready"); + expect(body).toContain("event: done"); + }); + + it("returns cache:suggestions on ambiguous name matches", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "comp-fpt" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: dummyProfile, report: dummyReport, diff: null } + ); + + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "Công ty CP FPT", + }, + }), + }); + + const response = await POST(req); + const body = await response.text(); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(body).toContain("event: cache:suggestions"); + expect(body).toContain("comp-fpt"); + expect(body).toContain("event: done"); + }); + + it("returns identity_conflict error on conflicting tax-ID and domain input", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "comp-fpt" + ); + await storage.resolveOrCreateIdentity( + { taxId: "0101245486", domain: "vingroup.net", name: "tập đoàn vingroup" }, + "comp-vin" + ); + + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "Conflicting Corp", + taxId: "0101248141", + website: "https://vingroup.net", + }, + }), + }); + + const response = await POST(req); + expect(response.status).toBe(409); + const json = await response.json(); + expect(json.code).toBe("identity_conflict"); + }); + + it("resolves cache selection when action is select", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "comp-fpt" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: dummyProfile, report: dummyReport, diff: null } + ); + + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "Công ty CP FPT", + }, + cache: { + action: "select", + companyId: "comp-fpt", + }, + }), + }); + + const response = await POST(req); + const body = await response.text(); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(body).toContain("event: cache:hit"); + expect(body).toContain("user_selection"); + expect(body).toContain("event: profile:ready"); + }); + + it("returns invalid_cache_selection error on invalid selectedCompanyId", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "comp-fpt" + ); + + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "Công ty CP FPT", + }, + cache: { + action: "select", + companyId: "unrelated-uuid", + }, + }), + }); + + const response = await POST(req); + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe("invalid_cache_selection"); + }); + + it("bypasses cache when action is bypass", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "comp-fpt" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: dummyProfile, report: dummyReport, diff: null } + ); + + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "FPT", + taxId: "0101248141", + }, + cache: { + action: "bypass", + }, + }), + }); + + const response = await POST(req); + const body = await response.text(); + + expect(mockSearch).toHaveBeenCalled(); + expect(body).toContain("event: research:start"); + }); + + it("returns HTTP 400 when request body has invalid JSON", async () => { + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "invalid-json", + }); + + const response = await POST(req); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Invalid JSON"); + }); + + it("returns HTTP 400 when input validation fails", async () => { + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "", + }, + }), + }); + + const response = await POST(req); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation failed"); + }); + + it("returns identity_conflict on invalid refresh company ID", async () => { + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "Vingroup", + }, + cache: { + action: "refresh", + companyId: "nonexistent-id", + }, + }), + }); + + const response = await POST(req); + expect(response.status).toBe(409); + const json = await response.json(); + expect(json.code).toBe("identity_conflict"); + }); + + it("emits non-terminal cache_invalid notice and proceeds to live workflow on corrupt snapshot", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "comp-fpt" + ); + vi.spyOn(storage, "getLatestCompleteSnapshot").mockRejectedValueOnce( + new CacheInvalidError("Corrupted JSONB") + ); + + const req = new NextRequest("http://localhost:3000/api/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + name: "Công ty CP FPT", + taxId: "0101248141", + }, + }), + }); + + const response = await POST(req); + expect(response.status).toBe(200); + const body = await response.text(); + + expect(body).toContain("cache_invalid"); + expect(body).toContain("event: research:start"); + expect(mockSearch).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/research-cache.test.ts b/tests/unit/research-cache.test.ts new file mode 100644 index 0000000..7e41097 --- /dev/null +++ b/tests/unit/research-cache.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + normalizeCompanyIdentity, + decideCacheLookup, + type IdentityCandidate, + type NormalizedCompanyIdentity, + type ResearchCache, +} from "@/modules/cache"; +import type { CompanyProfile } from "@/lib/types"; +import type { MemoryStorageAdapter } from "@/adapters/storage/memory"; + +describe("Research Cache - Normalization", () => { + it("normalizes tax ID, domain, and Vietnamese name without dropping legal suffixes", () => { + expect( + normalizeCompanyIdentity({ + name: " CÔNG TY CP Ánh Dương ", + taxId: "0101-245.486", + website: "https://WWW.Example.VN:443/about?q=1", + }) + ).toEqual({ + taxId: "0101245486", + domain: "example.vn", + name: "công ty cp ánh dương", + }); + }); + + it("handles 13-digit tax IDs correctly", () => { + expect( + normalizeCompanyIdentity({ + name: "Chi nhánh FPT", + taxId: "0101248141-001", + }) + ).toEqual({ + taxId: "0101248141001", + domain: null, + name: "chi nhánh fpt", + }); + }); + + it("rejects a malformed supplied tax ID", () => { + expect(() => + normalizeCompanyIdentity({ name: "FPT", taxId: "abc" }) + ).toThrow("Mã số thuế phải có 10 hoặc 13 chữ số"); + + expect(() => + normalizeCompanyIdentity({ name: "FPT", taxId: "123456789" }) + ).toThrow("Mã số thuế phải có 10 hoặc 13 chữ số"); + + expect(() => + normalizeCompanyIdentity({ name: "FPT", taxId: "12345678901" }) + ).toThrow("Mã số thuế phải có 10 hoặc 13 chữ số"); + }); + + it("normalizes domain removing leading www. and trailing dot", () => { + expect( + normalizeCompanyIdentity({ + name: "Test Corp", + website: "http://www.sub.domain.vn./path", + }) + ).toEqual({ + taxId: null, + domain: "sub.domain.vn", + name: "test corp", + }); + }); + + it("handles null/optional taxId and website gracefully", () => { + expect( + normalizeCompanyIdentity({ + name: "Công ty TNHH Một Thành Viên", + }) + ).toEqual({ + taxId: null, + domain: null, + name: "công ty tnhh một thành viên", + }); + }); +}); + +describe("Research Cache - Decision Logic", () => { + const withTaxAndDomain: NormalizedCompanyIdentity = { + taxId: "0101245486", + domain: "vingroup.net", + name: "tập đoàn vingroup", + }; + + const candidatesForSameCompany: IdentityCandidate[] = [ + { + companyId: "company-a", + taxId: "0101245486", + domain: "vingroup.net", + name: "tập đoàn vingroup", + }, + ]; + + const withConflictingKeys: NormalizedCompanyIdentity = { + taxId: "0101245486", + domain: "fpt.com.vn", + name: "tập đoàn vingroup", + }; + + const conflictingCandidates: IdentityCandidate[] = [ + { + companyId: "company-a", + taxId: "0101245486", + domain: "vingroup.net", + name: "tập đoàn vingroup", + }, + { + companyId: "company-b", + taxId: "0101248141", + domain: "fpt.com.vn", + name: "công ty cp fpt", + }, + ]; + + const domainOnly: NormalizedCompanyIdentity = { + taxId: null, + domain: "shared-domain.vn", + name: "công ty a", + }; + + const twoDomainCandidates: IdentityCandidate[] = [ + { + companyId: "company-a", + taxId: "0101245486", + domain: "shared-domain.vn", + name: "công ty a", + }, + { + companyId: "company-b", + taxId: "0101248141", + domain: "shared-domain.vn", + name: "công ty b", + }, + ]; + + const nameOnly: NormalizedCompanyIdentity = { + taxId: null, + domain: null, + name: "công ty cp ánh dương", + }; + + const oneNameCandidate: IdentityCandidate[] = [ + { + companyId: "company-a", + taxId: "0101245486", + domain: "anhduong.vn", + name: "công ty cp ánh dương", + }, + ]; + + it("resolves exact tax ID and compatible domain to an automatic hit", () => { + expect(decideCacheLookup(withTaxAndDomain, candidatesForSameCompany)).toEqual({ + kind: "hit", + companyId: "company-a", + matchedBy: "tax_id", + }); + }); + + it("detects conflict when supplied tax ID and domain resolve to different companies", () => { + expect(decideCacheLookup(withConflictingKeys, conflictingCandidates)).toEqual({ + kind: "conflict", + taxCompanyId: "company-a", + domainCompanyIds: ["company-b"], + }); + }); + + it("returns suggestions for multi-candidate domain matches", () => { + expect(decideCacheLookup(domainOnly, twoDomainCandidates)).toEqual({ + kind: "suggestions", + companyIds: ["company-a", "company-b"], + }); + }); + + it("returns automatic hit for unique domain match without tax ID", () => { + const singleDomainCandidate: IdentityCandidate[] = [ + { + companyId: "company-a", + taxId: "0101245486", + domain: "unique-domain.vn", + name: "công ty a", + }, + ]; + expect( + decideCacheLookup( + { taxId: null, domain: "unique-domain.vn", name: "công ty a" }, + singleDomainCandidate + ) + ).toEqual({ + kind: "hit", + companyId: "company-a", + matchedBy: "domain", + }); + }); + + it("returns suggestions for name matches", () => { + expect(decideCacheLookup(nameOnly, oneNameCandidate)).toEqual({ + kind: "suggestions", + companyIds: ["company-a"], + }); + }); + + it("returns miss when no candidates match", () => { + expect(decideCacheLookup(nameOnly, [])).toEqual({ kind: "miss" }); + }); +}); + +describe("ResearchCache - Storage-backed Cache Module", () => { + let storage: MemoryStorageAdapter; + let cache: ResearchCache; + + const validProfile: CompanyProfile = { + id: "company-a", + version: 1, + createdAt: new Date("2026-08-26T00:00:00.000Z"), + lastUpdated: new Date("2026-08-26T08:00:00.000Z"), + input: { name: "FPT Corporation" }, + officialName: "Công ty Cổ phần FPT", + tradingNames: ["FPT"], + taxId: "0101248141", + industry: ["Technology"], + description: "Technology Corporation", + keyPeople: [], + products: [], + markets: [], + recentActivities: [], + sources: [], + overallConfidence: 0.95, + }; + + const validReport = { + companyId: "company-a", + generatedAt: new Date("2026-08-26T08:00:00.000Z"), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Executive Summary", + }; + + beforeEach(async () => { + const { MemoryStorageAdapter } = await import("@/adapters/storage/memory"); + const { createResearchCache } = await import("@/modules/cache"); + storage = new MemoryStorageAdapter(); + cache = createResearchCache(storage); + }); + + it("resolves tax-ID match to an immediate hit with complete snapshot", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "company-a" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: validProfile, report: validReport, diff: null } + ); + + const resolution = await cache.lookup({ name: "FPT", taxId: "0101248141" }); + expect(resolution).toMatchObject({ + kind: "hit", + matchedBy: "tax_id", + snapshot: { profile: { id: "company-a" } }, + }); + }); + + it("returns suggestions for name matches", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "company-a" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: validProfile, report: validReport, diff: null } + ); + + const resolution = await cache.lookup({ name: "Công ty CP FPT" }); + expect(resolution).toMatchObject({ + kind: "suggestions", + suggestions: [ + expect.objectContaining({ + companyId: "company-a", + officialName: "Công ty Cổ phần FPT", + }), + ], + }); + }); + + it("returns miss for unknown companies", async () => { + const resolution = await cache.lookup({ name: "Unknown Company" }); + expect(resolution).toEqual({ + kind: "miss", + identity: { taxId: null, domain: null, name: "unknown company" }, + cacheInvalid: false, + }); + }); + + it("rejects select when requested companyId is not in the suggestion candidate set", async () => { + // Seed company-a and company-b + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "company-a" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: validProfile, report: validReport, diff: null } + ); + + const profileB = { ...validProfile, id: "company-b", officialName: "Vingroup" }; + const reportB = { ...validReport, companyId: "company-b" }; + await storage.resolveOrCreateIdentity( + { taxId: "0101245486", domain: "vingroup.net", name: "tập đoàn vingroup" }, + "company-b" + ); + await storage.persistResearchSnapshot( + { taxId: "0101245486", domain: "vingroup.net", name: "tập đoàn vingroup" }, + { profile: profileB, report: reportB, diff: null } + ); + + await expect( + cache.select({ name: "Công ty CP FPT" }, "company-b") + ).rejects.toMatchObject({ code: "invalid_cache_selection" }); + }); + + it("rejects prepareRefresh when strong keys conflict with target company", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "company-a" + ); + await storage.persistResearchSnapshot( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + { profile: validProfile, report: validReport, diff: null } + ); + + await storage.resolveOrCreateIdentity( + { taxId: "0101245486", domain: "vingroup.net", name: "tập đoàn vingroup" }, + "company-b" + ); + const profileB = { ...validProfile, id: "company-b", officialName: "Vingroup" }; + const reportB = { ...validReport, companyId: "company-b" }; + await storage.persistResearchSnapshot( + { taxId: "0101245486", domain: "vingroup.net", name: "tập đoàn vingroup" }, + { profile: profileB, report: reportB, diff: null } + ); + + // Refreshing company-b with company-a's tax ID must reject with identity_conflict + await expect( + cache.prepareRefresh({ name: "Vingroup", taxId: "0101248141" }, "company-b") + ).rejects.toMatchObject({ code: "identity_conflict" }); + }); + + it("recovers from corrupt snapshot by returning miss with cacheInvalid: true", async () => { + await storage.resolveOrCreateIdentity( + { taxId: "0101248141", domain: "fpt.com.vn", name: "công ty cp fpt" }, + "company-a" + ); + + // Mock storage.getLatestCompleteSnapshot to throw CacheInvalidError + const { CacheInvalidError } = await import("@/modules/cache"); + vi.spyOn(storage, "getLatestCompleteSnapshot").mockRejectedValueOnce( + new CacheInvalidError("Corrupted data") + ); + + const resolution = await cache.lookup({ name: "FPT", taxId: "0101248141" }); + expect(resolution).toEqual({ + kind: "miss", + identity: { taxId: "0101248141", domain: null, name: "fpt" }, + cacheInvalid: true, + }); + }); +}); + diff --git a/tests/unit/research-route-observability.test.ts b/tests/unit/research-route-observability.test.ts index a9bb109..0aef6bc 100644 --- a/tests/unit/research-route-observability.test.ts +++ b/tests/unit/research-route-observability.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryStorageAdapter } from "@/adapters/storage/memory"; const observabilityMocks = vi.hoisted(() => ({ emitResearchScores: vi.fn(async () => undefined), @@ -6,12 +7,14 @@ const observabilityMocks = vi.hoisted(() => ({ updateResearchObservationOutcome: vi.fn(), })); +let mockStorage = new MemoryStorageAdapter(); + vi.mock("@/config", () => ({ createLLMAdapter: () => ({}), createSearchAdapter: () => ({}), createScraperAdapter: () => ({}), createRegistryAdapter: () => ({}), - createStorageAdapter: () => ({}), + createStorageAdapter: () => mockStorage, getGuards: () => ({}), })); vi.mock("@/modules/profile", () => ({ createProfileModule: () => ({}) })); @@ -42,6 +45,7 @@ import { POST } from "@/app/api/research/route"; describe("Research route observability", () => { beforeEach(() => { vi.clearAllMocks(); + mockStorage = new MemoryStorageAdapter(); }); it("marks and scores an unexpected workflow failure", async () => { @@ -49,7 +53,7 @@ describe("Research route observability", () => { new NextRequest("http://localhost:3000/api/research", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "FPT" }), + body: JSON.stringify({ input: { name: "FPT" } }), }), ); diff --git a/tests/unit/supabase-storage.test.ts b/tests/unit/supabase-storage.test.ts index a164b0e..8aaef0a 100644 --- a/tests/unit/supabase-storage.test.ts +++ b/tests/unit/supabase-storage.test.ts @@ -1,21 +1,25 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { SupabaseStorageAdapter } from "@/adapters/storage/supabase"; +import type { CompanyProfile } from "@/lib/types"; describe("SupabaseStorageAdapter Unit Tests", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + it("throws error when initialized without credentials", () => { delete process.env.SUPABASE_URL; - delete process.env.SUPABASE_ANON_KEY; delete process.env.SUPABASE_SERVICE_ROLE_KEY; expect(() => new SupabaseStorageAdapter()).toThrow( - "Missing Supabase credentials: SUPABASE_URL or SUPABASE_ANON_KEY" + "Missing Supabase credentials: SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY" ); }); - it("initializes client properly with URL and Key", () => { + it("initializes client properly with URL and Service Role Key", () => { const adapter = new SupabaseStorageAdapter( "https://example.supabase.co", - "mock-anon-key" + "mock-service-role-key" ); expect(adapter).toBeDefined(); expect(typeof adapter.saveProfile).toBe("function"); @@ -24,5 +28,157 @@ describe("SupabaseStorageAdapter Unit Tests", () => { expect(typeof adapter.listProfiles).toBe("function"); expect(typeof adapter.saveDiff).toBe("function"); expect(typeof adapter.getDiffs).toBe("function"); + expect(typeof adapter.findIdentityCandidates).toBe("function"); + expect(typeof adapter.getLatestCompleteSnapshot).toBe("function"); + expect(typeof adapter.resolveOrCreateIdentity).toBe("function"); + expect(typeof adapter.persistResearchSnapshot).toBe("function"); + }); + + it("calls lookup_company_identities RPC with normalized values", async () => { + const adapter = new SupabaseStorageAdapter( + "https://example.supabase.co", + "mock-service-role-key" + ); + + const mockRpc = vi.fn().mockReturnValue({ + abortSignal: vi.fn(), + then: (resolve: (val: unknown) => unknown) => + Promise.resolve( + resolve({ + data: [ + { + id: "comp-1", + tax_id: "0101245486", + normalized_domain: "vingroup.net", + normalized_name: "tập đoàn vingroup", + }, + ], + error: null, + }) + ), + }); + + (adapter as unknown as { client: { rpc: unknown } }).client = { + rpc: mockRpc, + }; + + const identity = { + taxId: "0101245486", + domain: "vingroup.net", + name: "tập đoàn vingroup", + }; + + const candidates = await adapter.findIdentityCandidates(identity); + expect(mockRpc).toHaveBeenCalledWith("lookup_company_identities", { + p_tax_id: "0101245486", + p_domain: "vingroup.net", + p_name: "tập đoàn vingroup", + }); + expect(candidates).toEqual([ + { + companyId: "comp-1", + taxId: "0101245486", + domain: "vingroup.net", + name: "tập đoàn vingroup", + }, + ]); + }); + + it("calls resolve_company_identity RPC and handles conflicts", async () => { + const adapter = new SupabaseStorageAdapter( + "https://example.supabase.co", + "mock-service-role-key" + ); + + const mockRpc = vi.fn().mockReturnValue({ + abortSignal: vi.fn(), + then: (resolve: (val: unknown) => unknown) => + Promise.resolve( + resolve({ + data: null, + error: { message: "identity_conflict: conflicting domain" }, + }) + ), + }); + + (adapter as unknown as { client: { rpc: unknown } }).client = { + rpc: mockRpc, + }; + + await expect( + adapter.resolveOrCreateIdentity( + { taxId: "0101245486", domain: "vingroup.net", name: "vingroup" }, + "candidate-1" + ) + ).rejects.toThrow("Thông tin định danh công ty mâu thuẫn."); + }); + + it("calls persist_research_snapshot RPC and returns parsed snapshot", async () => { + const adapter = new SupabaseStorageAdapter( + "https://example.supabase.co", + "mock-service-role-key" + ); + + const mockRpc = vi.fn().mockReturnValue({ + abortSignal: vi.fn(), + then: (resolve: (val: unknown) => unknown) => + Promise.resolve( + resolve({ + data: "2026-08-26T08:00:00.000Z", + error: null, + }) + ), + }); + + (adapter as unknown as { client: { rpc: unknown } }).client = { + rpc: mockRpc, + }; + + const dummyProfile: CompanyProfile = { + id: "comp-1", + version: 1, + createdAt: new Date("2026-08-26T00:00:00.000Z"), + lastUpdated: new Date("2026-08-26T08:00:00.000Z"), + input: { name: "Vingroup" }, + officialName: "Tập đoàn Vingroup", + tradingNames: [], + industry: ["Conglomerate"], + description: "Desc", + keyPeople: [], + products: [], + markets: [], + recentActivities: [], + sources: [], + overallConfidence: 0.9, + }; + + const draft = { + profile: dummyProfile, + report: { + companyId: "comp-1", + generatedAt: new Date("2026-08-26T08:00:00.000Z"), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Summary", + }, + diff: null, + }; + + const result = await adapter.persistResearchSnapshot( + { taxId: "0101245486", domain: "vingroup.net", name: "vingroup" }, + draft + ); + + expect(mockRpc).toHaveBeenCalledWith("persist_research_snapshot", { + p_company_id: "comp-1", + p_tax_id: "0101245486", + p_domain: "vingroup.net", + p_name: "vingroup", + p_version: 1, + p_profile_data: dummyProfile, + p_analysis_report: draft.report, + p_diff_data: null, + }); + expect(result.lastSyncedAt).toBe("2026-08-26T08:00:00.000Z"); }); }); diff --git a/tests/unit/types-validation.test.ts b/tests/unit/types-validation.test.ts index 1f2e8dc..9763af1 100644 --- a/tests/unit/types-validation.test.ts +++ b/tests/unit/types-validation.test.ts @@ -56,3 +56,135 @@ describe("Domain Validation - CompanyInputSchema", () => { expect(result.success).toBe(false); }); }); + +describe("ResearchRequestSchema and ResearchSnapshotSchema", () => { + const validProfileJson = { + id: "fpt-corp", + version: 1, + createdAt: "2026-08-26T00:00:00.000Z", + input: { name: "FPT Corporation" }, + officialName: "Công ty Cổ phần FPT", + tradingNames: ["FPT Corp"], + taxId: "0101248141", + industry: ["Technology"], + description: "Technology corporation in Vietnam", + foundedYear: 1988, + headquarters: { country: "Vietnam", city: "Hanoi" }, + website: "https://fpt.com.vn", + keyPeople: [ + { + name: "Trương Gia Bình", + title: "Chủ tịch HĐQT", + source: "website", + confidence: 0.9, + }, + ], + products: ["FPT Software", "FPT Telecom"], + markets: ["Vietnam", "Global"], + companySize: "1000+", + revenue: "> 1T VND", + recentActivities: [ + { + date: "2026-08-20T00:00:00.000Z", + title: "AI Expansion", + summary: "FPT expands AI research hub", + url: "https://fpt.com.vn/news/1", + source: "news", + }, + ], + lastUpdated: "2026-08-26T08:00:00.000Z", + sources: [ + { + source: "website", + url: "https://fpt.com.vn", + accessedAt: "2026-08-26T08:00:00.000Z", + fieldsContributed: ["officialName", "products"], + }, + ], + overallConfidence: 0.95, + }; + + const validReportJson = { + companyId: "fpt-corp", + generatedAt: "2026-08-26T08:00:00.000Z", + fitScore: { + score: 88, + reasoning: "Strong industry alignment and solid financial health.", + criteria: [ + { name: "Industry Alignment", score: 90, weight: 0.3, reasoning: "Tech fit" }, + ], + }, + riskFlags: [ + { + type: "operational", + description: "Talent competition", + severity: "low", + source: "news", + }, + ], + suggestedActions: [ + { + action: "Partner on Cloud transformation", + priority: "high", + reasoning: "High capability match", + }, + ], + executiveSummary: "FPT is an ideal strategic technology partner.", + }; + + it("accepts default, select, refresh, and bypass research requests", async () => { + const { ResearchRequestSchema } = await import("@/lib/types"); + const requests = [ + { input: { name: "FPT" } }, + { input: { name: "FPT" }, cache: { action: "select", companyId: "fpt" } }, + { input: { name: "FPT" }, cache: { action: "refresh", companyId: "fpt" } }, + { input: { name: "FPT" }, cache: { action: "bypass" } }, + ]; + + expect(requests.every((request) => ResearchRequestSchema.safeParse(request).success)) + .toBe(true); + }); + + it("rejects cache actions with missing or unexpected company IDs", async () => { + const { ResearchRequestSchema } = await import("@/lib/types"); + expect( + ResearchRequestSchema.safeParse({ + input: { name: "FPT" }, + cache: { action: "select" }, + }).success, + ).toBe(false); + expect( + ResearchRequestSchema.safeParse({ + input: { name: "FPT" }, + cache: { action: "bypass", companyId: "injected" }, + }).success, + ).toBe(false); + }); + + it("parses a complete research snapshot and restores dates", async () => { + const { ResearchSnapshotSchema } = await import("@/lib/types"); + const result = ResearchSnapshotSchema.parse({ + profile: validProfileJson, + report: validReportJson, + diff: null, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }); + + expect(result.profile.lastUpdated).toBeInstanceOf(Date); + expect(result.report.generatedAt).toBeInstanceOf(Date); + expect(result.profile.createdAt).toBeInstanceOf(Date); + }); + + it("rejects mismatched and incomplete snapshots", async () => { + const { ResearchSnapshotSchema } = await import("@/lib/types"); + expect(() => + ResearchSnapshotSchema.parse({ + profile: validProfileJson, + report: { ...validReportJson, companyId: "other-company" }, + diff: null, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }), + ).toThrow(); + }); +}); + diff --git a/tests/unit/use-research-reducer.test.ts b/tests/unit/use-research-reducer.test.ts new file mode 100644 index 0000000..7d3fe6e --- /dev/null +++ b/tests/unit/use-research-reducer.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from "vitest"; +import { + reduceResearchEvent, + buildResearchRequest, + INITIAL_STATE, + type ResearchState, +} from "@/app/hooks/use-research"; +import type { StreamEvent, CompanyProfile, AnalysisReport, ProfileDiff } from "@/lib/types"; + +describe("useResearch request builder - buildResearchRequest", () => { + it("builds default, selected, bypass, and refresh requests", () => { + const input = { name: "FPT" }; + + expect(buildResearchRequest(input)).toEqual({ input }); + expect(buildResearchRequest(input, { action: "select", companyId: "fpt" })) + .toEqual({ input, cache: { action: "select", companyId: "fpt" } }); + expect(buildResearchRequest(input, { action: "bypass" })) + .toEqual({ input, cache: { action: "bypass" } }); + expect(buildResearchRequest(input, { action: "refresh", companyId: "fpt" })) + .toEqual({ input, cache: { action: "refresh", companyId: "fpt" } }); + }); +}); + +describe("useResearch pure reducer - reduceResearchEvent", () => { + const dummyProfile: CompanyProfile = { + id: "comp-1", + version: 1, + createdAt: new Date(), + lastUpdated: new Date(), + input: { name: "Test" }, + officialName: "Test Corp", + tradingNames: [], + industry: ["Tech"], + description: "Desc", + keyPeople: [], + products: [], + markets: [], + recentActivities: [], + sources: [], + overallConfidence: 0.9, + }; + + const dummyDiff: ProfileDiff = { + companyId: "comp-1", + fromVersion: 1, + toVersion: 2, + summary: "Markets updated", + changes: [ + { + field: "markets", + changeType: "modified", + oldValue: ["Việt Nam"], + newValue: ["Việt Nam", "Mỹ"], + significance: "medium", + }, + ], + }; + + const dummyReport: AnalysisReport = { + companyId: "comp-1", + generatedAt: new Date(), + riskFlags: [], + suggestedActions: [], + executiveSummary: "Executive Summary", + }; + + it("resets errors on research:start", () => { + const errorState: ResearchState = { + ...INITIAL_STATE, + status: "error", + error: "Previous error", + errorCode: "identity_conflict", + }; + + const nextState = reduceResearchEvent(errorState, { + event: "research:start", + data: { sources: ["web_search", "news"] }, + }); + + expect(nextState.status).toBe("researching"); + expect(nextState.error).toBeNull(); + expect(nextState.errorCode).toBeUndefined(); + expect(nextState.sourceStatuses.web_search).toBe("idle"); + }); + + it("updates individual source statuses on research:progress", () => { + let state = reduceResearchEvent(INITIAL_STATE, { + event: "research:progress", + data: { source: "web_search", status: "started" }, + }); + expect(state.sourceStatuses.web_search).toBe("started"); + expect(state.sourceStatuses.news).toBe("idle"); + + state = reduceResearchEvent(state, { + event: "research:progress", + data: { source: "web_search", status: "done" }, + }); + expect(state.sourceStatuses.web_search).toBe("done"); + }); + + it("appends findings on research:finding", () => { + let state = reduceResearchEvent(INITIAL_STATE, { + event: "research:finding", + data: { source: "web_search", summary: "Finding 1" }, + }); + state = reduceResearchEvent(state, { + event: "research:finding", + data: { source: "news", summary: "Finding 2" }, + }); + + expect(state.findings).toHaveLength(2); + expect(state.findings[0]).toEqual({ source: "web_search", summary: "Finding 1" }); + expect(state.findings[1]).toEqual({ source: "news", summary: "Finding 2" }); + }); + + it("sets building status on profile:building", () => { + const nextState = reduceResearchEvent(INITIAL_STATE, { + event: "profile:building", + data: { message: "Building..." }, + }); + expect(nextState.status).toBe("building"); + }); + + it("records profile diff on diff:ready", () => { + const nextState = reduceResearchEvent(INITIAL_STATE, { + event: "diff:ready", + data: { diff: dummyDiff }, + }); + expect(nextState.diff).toEqual(dummyDiff); + }); + + it("handles cache:hit event and records metadata", () => { + const event: StreamEvent = { + event: "cache:hit", + data: { + companyId: "comp-1", + matchedBy: "tax_id", + version: 1, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }, + }; + + const nextState = reduceResearchEvent(INITIAL_STATE, event); + expect(nextState.cacheHit).toEqual({ + matchedBy: "tax_id", + version: 1, + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }); + }); + + it("transitions to suggesting state on cache:suggestions", () => { + const event: StreamEvent = { + event: "cache:suggestions", + data: { + suggestions: [ + { + companyId: "comp-1", + officialName: "FPT Corporation", + taxId: "0101248141", + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }, + ], + }, + }; + + const nextState = reduceResearchEvent(INITIAL_STATE, event); + expect(nextState.status).toBe("suggesting"); + expect(nextState.suggestions).toHaveLength(1); + expect(nextState.suggestions[0].companyId).toBe("comp-1"); + }); + + it("preserves suggesting status on done event", () => { + const suggestingState: ResearchState = { + ...INITIAL_STATE, + status: "suggesting", + suggestions: [ + { + companyId: "comp-1", + officialName: "FPT Corporation", + lastSyncedAt: "2026-08-26T08:00:00.000Z", + }, + ], + }; + + const event: StreamEvent = { + event: "done", + data: {}, + }; + + const nextState = reduceResearchEvent(suggestingState, event); + expect(nextState.status).toBe("suggesting"); + }); + + it("records error and error code on error event", () => { + const event: StreamEvent = { + event: "error", + data: { + message: "Thông tin định danh công ty mâu thuẫn.", + code: "identity_conflict", + }, + }; + + const nextState = reduceResearchEvent(INITIAL_STATE, event); + expect(nextState.error).toBe("Thông tin định danh công ty mâu thuẫn."); + expect(nextState.errorCode).toBe("identity_conflict"); + }); + + it("records notice without setting error status on cache_invalid error event", () => { + const researchingState: ResearchState = { + ...INITIAL_STATE, + status: "researching", + }; + + const event: StreamEvent = { + event: "error", + data: { + message: "Dữ liệu cache không hợp lệ, đang tiến hành nghiên cứu mới.", + code: "cache_invalid", + }, + }; + + const nextState = reduceResearchEvent(researchingState, event); + expect(nextState.status).toBe("researching"); + expect(nextState.error).toBeNull(); + expect(nextState.notice).toBe("Dữ liệu cache không hợp lệ, đang tiến hành nghiên cứu mới."); + }); + + it("transitions to done when profile and analysis are ready", () => { + let state = reduceResearchEvent(INITIAL_STATE, { + event: "profile:ready", + data: { profile: dummyProfile }, + }); + state = reduceResearchEvent(state, { + event: "analysis:ready", + data: { report: dummyReport }, + }); + state = reduceResearchEvent(state, { + event: "done", + data: {}, + }); + + expect(state.status).toBe("done"); + expect(state.profile?.id).toBe("comp-1"); + expect(state.report?.companyId).toBe("comp-1"); + }); +});