Commit ·
0762fba
0
Parent(s):
feat: initial commit - core multi-agent compiler engine and frontend UI
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +42 -0
- CLAUDE.md +444 -0
- backend/.env.example +4 -0
- backend/agent/brain_agent.py +144 -0
- backend/db/schema.sql +58 -0
- backend/db/supabase.py +63 -0
- backend/graph/graph.py +30 -0
- backend/graph/nodes/cluster_evidence.py +64 -0
- backend/graph/nodes/load_and_chunk.py +174 -0
- backend/graph/nodes/quality_normalize.py +83 -0
- backend/graph/nodes/synthesize_skills.py +111 -0
- backend/graph/nodes/write_brain.py +96 -0
- backend/graph/state.py +14 -0
- backend/llm.py +65 -0
- backend/main.py +310 -0
- backend/models/schemas.py +20 -0
- backend/requirements.txt +10 -0
- backend/sse.py +40 -0
- backend/test_compile.py +89 -0
- brand_alchemy_company_brain.html +254 -0
- company_brain_PRD_v4.md +1061 -0
- data/sources/rivanly-inc/notion_cs_playbook.md +10 -0
- data/sources/rivanly-inc/notion_eng_runbook.md +17 -0
- data/sources/rivanly-inc/notion_hr_playbook.md +17 -0
- data/sources/rivanly-inc/notion_pricing_policy.md +14 -0
- data/sources/rivanly-inc/notion_refund_sop.md +16 -0
- data/sources/rivanly-inc/slack_export_ops.json +20 -0
- data/sources/rivanly-inc/slack_export_support.json +26 -0
- data/sources/rivanly-inc/zendesk_tickets.json +23 -0
- frontend/.gitignore +41 -0
- frontend/AGENTS.md +5 -0
- frontend/CLAUDE.md +1 -0
- frontend/README.md +36 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next.config.ts +7 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +26 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/next.svg +1 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/src/app/compile/[jobId]/page.tsx +115 -0
- frontend/src/app/demo/[companyId]/page.tsx +269 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/globals.css +24 -0
- frontend/src/app/layout.tsx +33 -0
- frontend/src/app/page.tsx +90 -0
- frontend/src/app/skills/[companyId]/page.tsx +162 -0
.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# OS files
|
| 7 |
+
.DS_Store
|
| 8 |
+
Thumbs.db
|
| 9 |
+
|
| 10 |
+
# Environments & Secret credentials
|
| 11 |
+
.env
|
| 12 |
+
.env.local
|
| 13 |
+
.env*.local
|
| 14 |
+
backend/.env
|
| 15 |
+
frontend/.env
|
| 16 |
+
|
| 17 |
+
# Local database files
|
| 18 |
+
*.db
|
| 19 |
+
*.sqlite
|
| 20 |
+
*.sqlite3
|
| 21 |
+
*.sqlite-journal
|
| 22 |
+
backend/db/local_brain.db
|
| 23 |
+
|
| 24 |
+
# Node dependencies & Next.js cache
|
| 25 |
+
node_modules/
|
| 26 |
+
.next/
|
| 27 |
+
out/
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# Unit test & Coverage reports
|
| 34 |
+
.pytest_cache/
|
| 35 |
+
.coverage
|
| 36 |
+
htmlcov/
|
| 37 |
+
.cache/
|
| 38 |
+
|
| 39 |
+
# User data sources (ignore uploaded dynamic sources, keep demo ones if needed)
|
| 40 |
+
# For the hackathon, we keep the static demo rivanly-inc files, but ignore other companies if uploaded
|
| 41 |
+
data/sources/*/
|
| 42 |
+
!data/sources/rivanly-inc/
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Company Brain — CLAUDE.md
|
| 2 |
+
## Project context for AI coding assistants
|
| 3 |
+
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
## What This Project Is
|
| 7 |
+
|
| 8 |
+
Company Brain is a multi-agent compilation pipeline that extracts operational decision knowledge from company data sources (Slack, Notion SOPs, support tickets) and compiles it into a versioned, evidence-linked, executable skills file. A downstream brain agent uses this skills file to handle operational scenarios correctly — acting like the company's best employee.
|
| 9 |
+
|
| 10 |
+
**The core thesis:** Agents are compilers, not assistants. We don't search raw documents. We compile tribal knowledge into structured, executable logic once. Then we read the compiled output forever.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Monorepo Structure
|
| 15 |
+
|
| 16 |
+
```
|
| 17 |
+
company-brain/
|
| 18 |
+
├── backend/ ← FastAPI + LangGraph pipeline (Python)
|
| 19 |
+
│ ├── main.py ← FastAPI app entry point
|
| 20 |
+
│ ├── graph/
|
| 21 |
+
│ │ ├── state.py ← BrainState TypedDict
|
| 22 |
+
│ │ ├── nodes/ ← one file per LangGraph node
|
| 23 |
+
│ │ │ ├── ingest_slack.py
|
| 24 |
+
│ │ │ ├── ingest_notion.py
|
| 25 |
+
│ │ │ ├── ingest_tickets.py
|
| 26 |
+
│ │ │ ├── ingest_join.py
|
| 27 |
+
│ │ │ ├── extract_decisions.py
|
| 28 |
+
│ │ │ ├── extract_workflows.py
|
| 29 |
+
│ │ │ ├── extract_exceptions.py
|
| 30 |
+
│ │ │ ├── detect_contradictions.py
|
| 31 |
+
│ │ │ ├── synthesize_skills.py
|
| 32 |
+
│ │ │ ├── link_evidence.py
|
| 33 |
+
│ │ │ ├── score_confidence.py
|
| 34 |
+
│ │ │ └── write_brain.py
|
| 35 |
+
│ │ └── graph.py ← graph assembly + compile
|
| 36 |
+
│ ├── agents/
|
| 37 |
+
│ │ └── brain_agent.py ← query-time brain agent
|
| 38 |
+
│ ├── db/
|
| 39 |
+
│ │ └── supabase.py ← Supabase client + queries
|
| 40 |
+
│ ├── models/
|
| 41 |
+
│ │ └── schemas.py ← Pydantic models for API
|
| 42 |
+
│ └── requirements.txt
|
| 43 |
+
├── frontend/ ← Next.js 14 + Tailwind (Harshit)
|
| 44 |
+
├── data/
|
| 45 |
+
│ └── sources/ ← 8 synthetic source files
|
| 46 |
+
│ ├── notion_refund_sop.md
|
| 47 |
+
│ ├── notion_pricing_policy.md
|
| 48 |
+
│ ├── notion_eng_runbook.md
|
| 49 |
+
│ ├── notion_hr_playbook.md
|
| 50 |
+
│ ├── notion_cs_playbook.md
|
| 51 |
+
│ ├── slack_export_support.json
|
| 52 |
+
│ ├── slack_export_ops.json
|
| 53 |
+
│ └── zendesk_tickets.json
|
| 54 |
+
└── CLAUDE.md ← this file
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Tech Stack
|
| 60 |
+
|
| 61 |
+
| Layer | Technology |
|
| 62 |
+
|---|---|
|
| 63 |
+
| LLM inference | vLLM serving `RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic` on AMD MI300X, port 8000 |
|
| 64 |
+
| LLM client | `openai` Python SDK pointed at `http://localhost:8000/v1` |
|
| 65 |
+
| Agent orchestration | `langgraph` with async nodes + `Send` API for parallel fan-out |
|
| 66 |
+
| State checkpointing | `MemorySaver` (in-memory for v0) |
|
| 67 |
+
| Embedding (skill matching) | `sentence-transformers` `all-MiniLM-L6-v2` in-memory, CPU |
|
| 68 |
+
| Web framework | `FastAPI` with `uvicorn` |
|
| 69 |
+
| Real-time streaming | FastAPI `StreamingResponse` with `text/event-stream` |
|
| 70 |
+
| Database | Supabase (Postgres) via `supabase-py` |
|
| 71 |
+
| File storage | Supabase Storage |
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## LLM Client Setup
|
| 76 |
+
|
| 77 |
+
```python
|
| 78 |
+
from openai import AsyncOpenAI
|
| 79 |
+
|
| 80 |
+
llm = AsyncOpenAI(
|
| 81 |
+
base_url="http://localhost:8000/v1",
|
| 82 |
+
api_key="not-needed"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# All LLM calls use this pattern:
|
| 86 |
+
response = await llm.chat.completions.create(
|
| 87 |
+
model="RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic",
|
| 88 |
+
messages=[
|
| 89 |
+
{"role": "system", "content": system_prompt},
|
| 90 |
+
{"role": "user", "content": user_content}
|
| 91 |
+
],
|
| 92 |
+
temperature=0.1,
|
| 93 |
+
max_tokens=4096
|
| 94 |
+
)
|
| 95 |
+
result = response.choices[0].message.content
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
**Never use `openai.OpenAI()` — always use `AsyncOpenAI`. All nodes are async.**
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## BrainState — The Central Data Structure
|
| 103 |
+
|
| 104 |
+
```python
|
| 105 |
+
from typing import TypedDict, Annotated
|
| 106 |
+
import operator
|
| 107 |
+
|
| 108 |
+
class BrainState(TypedDict):
|
| 109 |
+
company_id: str
|
| 110 |
+
source_files: list[dict] # [{filename, content, sha256, type}]
|
| 111 |
+
|
| 112 |
+
# Ingestion outputs (parallel, accumulated with operator.add)
|
| 113 |
+
normalized_events: Annotated[list[dict], operator.add] # from Slack
|
| 114 |
+
structured_sops: Annotated[list[dict], operator.add] # from Notion
|
| 115 |
+
resolved_cases: Annotated[list[dict], operator.add] # from tickets
|
| 116 |
+
|
| 117 |
+
# Extraction outputs (parallel, accumulated with operator.add)
|
| 118 |
+
raw_decisions: Annotated[list[dict], operator.add]
|
| 119 |
+
workflow_steps: Annotated[list[dict], operator.add]
|
| 120 |
+
exception_rules: Annotated[list[dict], operator.add]
|
| 121 |
+
contradictions: Annotated[list[dict], operator.add]
|
| 122 |
+
|
| 123 |
+
# Compilation outputs (sequential)
|
| 124 |
+
draft_skills: list[dict]
|
| 125 |
+
skills_with_evidence: list[dict]
|
| 126 |
+
final_skills: list[dict]
|
| 127 |
+
|
| 128 |
+
# Metadata
|
| 129 |
+
job_id: str
|
| 130 |
+
brain_version: str
|
| 131 |
+
errors: Annotated[list[str], operator.add]
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
**The `Annotated[list, operator.add]` pattern is critical.** It allows multiple parallel nodes to write to the same list field without overwriting each other. Do not change this.
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## LangGraph Architecture — Fan-Out Pattern
|
| 139 |
+
|
| 140 |
+
```python
|
| 141 |
+
from langgraph.graph import StateGraph
|
| 142 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 143 |
+
from langgraph.types import Send
|
| 144 |
+
|
| 145 |
+
def route_to_ingestion(state: BrainState) -> list[Send]:
|
| 146 |
+
"""Fan out to 3 parallel ingestion nodes based on source file types."""
|
| 147 |
+
sends = []
|
| 148 |
+
for file in state["source_files"]:
|
| 149 |
+
if file["type"] == "slack_json":
|
| 150 |
+
sends.append(Send("ingest_slack", {"source_files": [file], ...}))
|
| 151 |
+
elif file["type"] == "notion_md":
|
| 152 |
+
sends.append(Send("ingest_notion", {"source_files": [file], ...}))
|
| 153 |
+
elif file["type"] == "tickets_json":
|
| 154 |
+
sends.append(Send("ingest_tickets", {"source_files": [file], ...}))
|
| 155 |
+
return sends
|
| 156 |
+
|
| 157 |
+
def route_to_extraction(state: BrainState) -> list[Send]:
|
| 158 |
+
"""Fan out to 4 parallel extraction nodes after ingestion join."""
|
| 159 |
+
return [
|
| 160 |
+
Send("extract_decisions", state),
|
| 161 |
+
Send("extract_workflows", state),
|
| 162 |
+
Send("extract_exceptions", state),
|
| 163 |
+
Send("detect_contradictions", state),
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
# Graph assembly:
|
| 167 |
+
# START → route_to_ingestion (conditional) → [ingest_slack, ingest_notion, ingest_tickets]
|
| 168 |
+
# → ingest_join (barrier, waits for all) → route_to_extraction (conditional)
|
| 169 |
+
# → [extract_decisions, extract_workflows, extract_exceptions, detect_contradictions]
|
| 170 |
+
# → synthesize_skills → link_evidence → score_confidence → write_brain → END
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
**Never use `graph.add_edge("extractor", "synthesize_skills")` for parallel nodes — this causes synthesize_skills to fire multiple times. Always use the `Send` API + barrier join node.**
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## Extraction Prompt Pattern
|
| 178 |
+
|
| 179 |
+
Every extraction node uses this prompt structure:
|
| 180 |
+
|
| 181 |
+
```python
|
| 182 |
+
SYSTEM = """You are a policy analyst. Your ONLY job is to extract {type} from company communications.
|
| 183 |
+
Output ONLY a JSON array. No preamble. No explanation. No markdown.
|
| 184 |
+
Each item must have exactly these fields: {schema}
|
| 185 |
+
If you find nothing, output: []
|
| 186 |
+
Example output: {example}"""
|
| 187 |
+
|
| 188 |
+
USER = """Extract all {type} from this company data:
|
| 189 |
+
{content}"""
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
- Temperature: always `0.1`
|
| 193 |
+
- Max tokens: `4096`
|
| 194 |
+
- Always wrap LLM call in try/except — on JSON parse failure, retry once with stricter prompt, then return `[]`
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## Skills File Schema (per skill)
|
| 199 |
+
|
| 200 |
+
```python
|
| 201 |
+
{
|
| 202 |
+
"id": "handle_refund_request", # snake_case
|
| 203 |
+
"name": "Handle Refund Request", # human readable
|
| 204 |
+
"domain": "support", # support|revenue|product_eng|customer_success|hr|finance_ops
|
| 205 |
+
"version": "1.0",
|
| 206 |
+
"confidence": 0.91, # 0.0 - 1.0
|
| 207 |
+
"stale": False,
|
| 208 |
+
"review_required": False, # True if confidence < 0.6
|
| 209 |
+
"last_updated": "2026-05-04T09:30:00Z",
|
| 210 |
+
"trigger": {
|
| 211 |
+
"phrases": ["refund", "money back"],
|
| 212 |
+
"conditions": ["customer mentions payment dissatisfaction"]
|
| 213 |
+
},
|
| 214 |
+
"decision_logic": [
|
| 215 |
+
{
|
| 216 |
+
"condition": "plan == 'annual' AND days_since_purchase <= 14",
|
| 217 |
+
"action": "approve_full_refund",
|
| 218 |
+
"note": "No-questions policy within 14 days.",
|
| 219 |
+
"evidence_sources": [
|
| 220 |
+
{
|
| 221 |
+
"source": "notion_refund_sop.md",
|
| 222 |
+
"excerpt": "Annual plan customers within 14 days...",
|
| 223 |
+
"confidence": 0.95
|
| 224 |
+
}
|
| 225 |
+
]
|
| 226 |
+
}
|
| 227 |
+
],
|
| 228 |
+
"forbidden_actions": [
|
| 229 |
+
"Never process refunds for lifetime deal accounts"
|
| 230 |
+
],
|
| 231 |
+
"escalation_chain": ["support_agent", "support_lead", "account_manager", "founder"],
|
| 232 |
+
"sla": "respond_within_2h, resolve_within_24h"
|
| 233 |
+
}
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## Confidence Scoring Formula
|
| 239 |
+
|
| 240 |
+
```python
|
| 241 |
+
def score_confidence(skill: dict, all_sources: list[dict]) -> float:
|
| 242 |
+
base = 0.5
|
| 243 |
+
|
| 244 |
+
# More sources = higher confidence
|
| 245 |
+
source_count = len(skill["decision_logic"][0].get("evidence_sources", []))
|
| 246 |
+
if source_count >= 3:
|
| 247 |
+
base += 0.25
|
| 248 |
+
elif source_count == 2:
|
| 249 |
+
base += 0.15
|
| 250 |
+
elif source_count == 1:
|
| 251 |
+
base += 0.05
|
| 252 |
+
|
| 253 |
+
# Recent sources = higher confidence
|
| 254 |
+
# (check source file last_modified if available)
|
| 255 |
+
base += 0.15 # assume recent for v0
|
| 256 |
+
|
| 257 |
+
# No contradictions for this skill = higher confidence
|
| 258 |
+
# (passed in from contradiction detector)
|
| 259 |
+
has_contradiction = False # check contradictions list
|
| 260 |
+
if not has_contradiction:
|
| 261 |
+
base += 0.10
|
| 262 |
+
|
| 263 |
+
return min(base, 1.0)
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
## Brain Agent Pattern
|
| 269 |
+
|
| 270 |
+
```python
|
| 271 |
+
from sentence_transformers import SentenceTransformer
|
| 272 |
+
import numpy as np
|
| 273 |
+
|
| 274 |
+
# Load once at startup
|
| 275 |
+
embedder = SentenceTransformer('all-MiniLM-L6-v2')
|
| 276 |
+
|
| 277 |
+
# Pre-compute skill embeddings (call after compile)
|
| 278 |
+
skill_embeddings = {} # {skill_id: np.array}
|
| 279 |
+
|
| 280 |
+
def compute_skill_embeddings(skills: list[dict]):
|
| 281 |
+
global skill_embeddings
|
| 282 |
+
for skill in skills:
|
| 283 |
+
text = f"{skill['name']} {' '.join(skill['trigger']['phrases'])}"
|
| 284 |
+
skill_embeddings[skill['id']] = embedder.encode(text)
|
| 285 |
+
|
| 286 |
+
def match_skill(query: str) -> tuple[str, float]:
|
| 287 |
+
query_emb = embedder.encode(query)
|
| 288 |
+
scores = {}
|
| 289 |
+
for skill_id, emb in skill_embeddings.items():
|
| 290 |
+
score = float(np.dot(query_emb, emb) /
|
| 291 |
+
(np.linalg.norm(query_emb) * np.linalg.norm(emb)))
|
| 292 |
+
scores[skill_id] = score
|
| 293 |
+
best_id = max(scores, key=scores.get)
|
| 294 |
+
return best_id, scores[best_id]
|
| 295 |
+
|
| 296 |
+
def skill_to_markdown(skill: dict) -> str:
|
| 297 |
+
"""Convert skill JSON to markdown for prompt injection."""
|
| 298 |
+
lines = [f"## {skill['name']}", ""]
|
| 299 |
+
for logic in skill['decision_logic']:
|
| 300 |
+
lines.append(f"- IF {logic['condition']}: {logic['action']}")
|
| 301 |
+
if logic.get('note'):
|
| 302 |
+
lines.append(f" Note: {logic['note']}")
|
| 303 |
+
lines.append("")
|
| 304 |
+
lines.append("FORBIDDEN: " + "; ".join(skill['forbidden_actions']))
|
| 305 |
+
lines.append("ESCALATE: " + " → ".join(skill['escalation_chain']))
|
| 306 |
+
return "\n".join(lines)
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
## FastAPI SSE Pattern
|
| 312 |
+
|
| 313 |
+
```python
|
| 314 |
+
from fastapi import FastAPI
|
| 315 |
+
from fastapi.responses import StreamingResponse
|
| 316 |
+
import asyncio
|
| 317 |
+
import json
|
| 318 |
+
|
| 319 |
+
async def event_generator(job_id: str):
|
| 320 |
+
"""Yields SSE events during compilation."""
|
| 321 |
+
async for event in compilation_events[job_id]:
|
| 322 |
+
yield f"event: {event['type']}\ndata: {json.dumps(event['data'])}\n\n"
|
| 323 |
+
|
| 324 |
+
@app.get("/compile/stream")
|
| 325 |
+
async def stream_compile(job_id: str):
|
| 326 |
+
return StreamingResponse(
|
| 327 |
+
event_generator(job_id),
|
| 328 |
+
media_type="text/event-stream",
|
| 329 |
+
headers={
|
| 330 |
+
"Cache-Control": "no-cache",
|
| 331 |
+
"Connection": "keep-alive",
|
| 332 |
+
"Access-Control-Allow-Origin": "*" # CORS for frontend
|
| 333 |
+
}
|
| 334 |
+
)
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## Supabase Tables
|
| 340 |
+
|
| 341 |
+
```sql
|
| 342 |
+
-- Run these in Supabase SQL editor before starting
|
| 343 |
+
|
| 344 |
+
CREATE TABLE companies (
|
| 345 |
+
id TEXT PRIMARY KEY,
|
| 346 |
+
name TEXT NOT NULL,
|
| 347 |
+
created_at TIMESTAMPTZ DEFAULT now()
|
| 348 |
+
);
|
| 349 |
+
|
| 350 |
+
INSERT INTO companies VALUES ('rivanly-inc', 'Rivanly Inc.', now());
|
| 351 |
+
|
| 352 |
+
CREATE TABLE skills_files (
|
| 353 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 354 |
+
company_id TEXT REFERENCES companies(id),
|
| 355 |
+
version TEXT NOT NULL,
|
| 356 |
+
brain_json JSONB NOT NULL,
|
| 357 |
+
source_hashes JSONB NOT NULL,
|
| 358 |
+
compiled_at TIMESTAMPTZ DEFAULT now(),
|
| 359 |
+
is_current BOOLEAN DEFAULT false
|
| 360 |
+
);
|
| 361 |
+
|
| 362 |
+
CREATE UNIQUE INDEX idx_one_current_per_company
|
| 363 |
+
ON skills_files(company_id) WHERE is_current = true;
|
| 364 |
+
|
| 365 |
+
CREATE TABLE compile_runs (
|
| 366 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 367 |
+
company_id TEXT REFERENCES companies(id),
|
| 368 |
+
status TEXT CHECK (status IN ('started','running','complete','error')),
|
| 369 |
+
started_at TIMESTAMPTZ DEFAULT now(),
|
| 370 |
+
completed_at TIMESTAMPTZ,
|
| 371 |
+
duration_ms INTEGER,
|
| 372 |
+
result_version TEXT,
|
| 373 |
+
error_detail TEXT
|
| 374 |
+
);
|
| 375 |
+
|
| 376 |
+
CREATE TABLE source_files (
|
| 377 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 378 |
+
company_id TEXT REFERENCES companies(id),
|
| 379 |
+
filename TEXT NOT NULL,
|
| 380 |
+
sha256 TEXT NOT NULL,
|
| 381 |
+
content TEXT NOT NULL,
|
| 382 |
+
source_type TEXT CHECK (source_type IN ('slack_json','notion_md','tickets_json')),
|
| 383 |
+
uploaded_at TIMESTAMPTZ DEFAULT now()
|
| 384 |
+
);
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
---
|
| 388 |
+
|
| 389 |
+
## Environment Variables
|
| 390 |
+
|
| 391 |
+
```bash
|
| 392 |
+
# backend/.env
|
| 393 |
+
VLLM_BASE_URL=http://localhost:8000/v1
|
| 394 |
+
SUPABASE_URL=your_supabase_project_url
|
| 395 |
+
SUPABASE_KEY=your_supabase_anon_key
|
| 396 |
+
COMPANY_ID=rivanly-inc
|
| 397 |
+
```
|
| 398 |
+
|
| 399 |
+
---
|
| 400 |
+
|
| 401 |
+
## API Endpoints — Full List
|
| 402 |
+
|
| 403 |
+
```
|
| 404 |
+
POST /compile → trigger pipeline, returns {job_id, stream_url}
|
| 405 |
+
GET /compile/stream → SSE stream for job_id
|
| 406 |
+
GET /brain/status → current brain version + stats
|
| 407 |
+
GET /skills → all skills (lightweight)
|
| 408 |
+
GET /skills/{id} → full skill detail
|
| 409 |
+
POST /agent/handle → brain agent query
|
| 410 |
+
GET /diff/{v1}/{v2} → version diff
|
| 411 |
+
POST /sources/upload → upload source files
|
| 412 |
+
```
|
| 413 |
+
|
| 414 |
+
---
|
| 415 |
+
|
| 416 |
+
## Critical Rules — Do Not Violate
|
| 417 |
+
|
| 418 |
+
1. **All LangGraph nodes must be `async def`** — sync nodes break parallelism
|
| 419 |
+
2. **Use `Send` API for fan-out, never direct edges between parallel nodes and their join**
|
| 420 |
+
3. **Never read raw source files at query time** — brain agent reads skills file only
|
| 421 |
+
4. **All LLM calls wrapped in try/except** — retry once on JSON parse failure, return `[]` if still failing
|
| 422 |
+
5. **`skills_files.is_current` enforced by partial unique index** — only one current per company
|
| 423 |
+
6. **`compile_runs` table is append-only** — never update rows, only insert
|
| 424 |
+
7. **CORS headers on all endpoints** — frontend is on different domain
|
| 425 |
+
8. **Temperature 0.1 on all extraction calls** — deterministic is better than creative here
|
| 426 |
+
|
| 427 |
+
---
|
| 428 |
+
|
| 429 |
+
## Demo Company — Rivanly Inc.
|
| 430 |
+
|
| 431 |
+
The demo uses Rivanly Inc. — a fictional 15-person B2B SaaS company.
|
| 432 |
+
|
| 433 |
+
6 departments, 12 skills:
|
| 434 |
+
|
| 435 |
+
| Department | Skills |
|
| 436 |
+
|---|---|
|
| 437 |
+
| Support | handle_refund_request, respond_to_outage |
|
| 438 |
+
| Revenue | handle_pricing_exception, evaluate_discount_request |
|
| 439 |
+
| Product/Eng | prioritize_bug_report, handle_sla_breach |
|
| 440 |
+
| Customer Success | evaluate_churn_risk, enterprise_onboarding_steps |
|
| 441 |
+
| HR | hiring_process_engineering, performance_pip_trigger |
|
| 442 |
+
| Finance | approve_vendor_payment, expense_policy_exception |
|
| 443 |
+
|
| 444 |
+
The 8 synthetic source files in `data/sources/` are authored to produce these 12 skills when processed by the pipeline.
|
backend/.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
VLLM_BASE_URL=http://<MI300X_IP>:8000/v1
|
| 2 |
+
SUPABASE_URL=your_supabase_project_url
|
| 3 |
+
SUPABASE_KEY=your_supabase_anon_key
|
| 4 |
+
COMPANY_ID=rivanly-inc
|
backend/agent/brain_agent.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from backend.db.supabase import get_client
|
| 3 |
+
from backend.llm import llm_call, get_embedding, cosine_similarity
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def handle_agent_query(company_id: str, scenario: str, context: dict = None, with_brain: bool = True) -> dict:
|
| 7 |
+
"""
|
| 8 |
+
Real agent query handler. No keyword routing, no hardcoded actions.
|
| 9 |
+
Everything flows through: retrieve skills -> build prompt -> call vLLM -> return raw result.
|
| 10 |
+
"""
|
| 11 |
+
if not with_brain:
|
| 12 |
+
return await _baseline_query(scenario, context)
|
| 13 |
+
|
| 14 |
+
# --- WITH BRAIN ---
|
| 15 |
+
db = get_client()
|
| 16 |
+
if not db:
|
| 17 |
+
return _error_response("Database connection failed.")
|
| 18 |
+
|
| 19 |
+
# 1. Fetch latest compiled skills
|
| 20 |
+
res = db.table("skills_files").select("brain_json").eq(
|
| 21 |
+
"company_id", company_id
|
| 22 |
+
).order("compiled_at", desc=True).limit(1).execute()
|
| 23 |
+
|
| 24 |
+
if not res.data:
|
| 25 |
+
return _error_response("No compiled brain found. Please compile first.")
|
| 26 |
+
|
| 27 |
+
skills = res.data[0]["brain_json"].get("skills", [])
|
| 28 |
+
if not skills:
|
| 29 |
+
return _error_response("Brain is empty — no skills compiled.")
|
| 30 |
+
|
| 31 |
+
# 2. Embed the query and score every skill
|
| 32 |
+
query_text = f"{scenario} {json.dumps(context or {})}"
|
| 33 |
+
query_emb = get_embedding(query_text)
|
| 34 |
+
|
| 35 |
+
scored = []
|
| 36 |
+
for i, skill in enumerate(skills):
|
| 37 |
+
skill_text = f"{skill.get('category', '')} {skill.get('rule', '')} {skill.get('rationale', '')}"
|
| 38 |
+
skill_emb = get_embedding(skill_text)
|
| 39 |
+
score = cosine_similarity(query_emb, skill_emb)
|
| 40 |
+
scored.append({"skill": skill, "score": round(score, 4), "index": i})
|
| 41 |
+
|
| 42 |
+
scored.sort(key=lambda x: x["score"], reverse=True)
|
| 43 |
+
top_results = scored[:5]
|
| 44 |
+
retrieval_scores = [s["score"] for s in top_results]
|
| 45 |
+
|
| 46 |
+
# 3. Build skills context for the LLM
|
| 47 |
+
skills_context = ""
|
| 48 |
+
for rank, s in enumerate(top_results):
|
| 49 |
+
sk = s["skill"]
|
| 50 |
+
skills_context += f"\n--- Skill #{rank+1} (retrieval_score: {s['score']}) ---\n"
|
| 51 |
+
skills_context += f"Category: {sk.get('category', 'Unknown')}\n"
|
| 52 |
+
skills_context += f"Rule: {sk.get('rule', '')}\n"
|
| 53 |
+
skills_context += f"Rationale: {sk.get('rationale', '')}\n"
|
| 54 |
+
skills_context += f"Evidence: {json.dumps(sk.get('evidence', []))}\n"
|
| 55 |
+
skills_context += f"Compiled Confidence: {sk.get('confidence', 'unknown')}\n"
|
| 56 |
+
|
| 57 |
+
# 4. Prompt the LLM - no example confidence values to bias it
|
| 58 |
+
prompt = """You are the Kernl Brain Agent. You have access to this company's compiled operational skills (retrieved below, ranked by relevance).
|
| 59 |
+
|
| 60 |
+
Your task:
|
| 61 |
+
1. Read the scenario and optional JSON context carefully.
|
| 62 |
+
2. Examine the retrieved skills and their retrieval_scores.
|
| 63 |
+
3. Determine whether any skill clearly applies to this scenario.
|
| 64 |
+
4. If a skill applies, state the specific recommended action from that skill's rule.
|
| 65 |
+
5. If NO skill applies, or if the input is nonsensical/gibberish, say so honestly.
|
| 66 |
+
|
| 67 |
+
CONFIDENCE SCORING - base it on real signals:
|
| 68 |
+
- retrieval_score < 0.3 -> scenario is likely unrelated to any skill -> confidence < 0.2
|
| 69 |
+
- retrieval_score 0.3-0.5 -> weak match -> confidence 0.2-0.5
|
| 70 |
+
- retrieval_score 0.5-0.7 -> moderate match -> confidence 0.5-0.75
|
| 71 |
+
- retrieval_score > 0.7 AND rule clearly addresses the scenario -> confidence 0.75-0.95
|
| 72 |
+
- Never exceed 0.95 unless the match is exact and unambiguous.
|
| 73 |
+
- Gibberish or nonsensical input -> confidence 0.0, recommended_action = "unable to determine"
|
| 74 |
+
|
| 75 |
+
Respond with ONLY a JSON object (no markdown fences, no text outside the JSON):
|
| 76 |
+
{
|
| 77 |
+
"recommended_action": "the specific action to take",
|
| 78 |
+
"rule_applied": "exact rule text from the best matching skill",
|
| 79 |
+
"evidence": ["evidence items from the skill"],
|
| 80 |
+
"skill_matched": "the category of the matched skill",
|
| 81 |
+
"confidence": 0.0,
|
| 82 |
+
"reasoning": "explain why this skill applies and how you chose the confidence level"
|
| 83 |
+
}"""
|
| 84 |
+
|
| 85 |
+
user_content = f"--- Scenario ---\n{scenario}\n\n--- Additional Context ---\n{json.dumps(context or {})}\n\n--- Retrieved Skills (ranked by relevance) ---\n{skills_context}"
|
| 86 |
+
|
| 87 |
+
response_str = await llm_call(prompt, user_content)
|
| 88 |
+
result = _parse_json(response_str)
|
| 89 |
+
result["retrieval_scores"] = retrieval_scores
|
| 90 |
+
return result
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def _baseline_query(scenario: str, context: dict = None) -> dict:
|
| 94 |
+
"""Without-brain baseline: LLM answers with zero company context."""
|
| 95 |
+
prompt = """You are a generic AI assistant. You have NO company-specific knowledge or policies.
|
| 96 |
+
Answer based only on general industry standards. Be honest about your lack of specific context.
|
| 97 |
+
Respond with ONLY a JSON object:
|
| 98 |
+
{
|
| 99 |
+
"recommended_action": "your general recommendation",
|
| 100 |
+
"rule_applied": "general industry standard you referenced",
|
| 101 |
+
"evidence": [],
|
| 102 |
+
"skill_matched": "none",
|
| 103 |
+
"confidence": 0.3,
|
| 104 |
+
"retrieval_scores": [],
|
| 105 |
+
"reasoning": "explain your reasoning, noting you lack company-specific context"
|
| 106 |
+
}"""
|
| 107 |
+
user_content = f"Scenario: {scenario}\nContext: {json.dumps(context or {})}"
|
| 108 |
+
response_str = await llm_call(prompt, user_content)
|
| 109 |
+
return _parse_json(response_str)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _parse_json(raw: str) -> dict:
|
| 113 |
+
"""Parse LLM response as JSON, stripping markdown fences."""
|
| 114 |
+
try:
|
| 115 |
+
clean = raw.strip()
|
| 116 |
+
if clean.startswith("```json"):
|
| 117 |
+
clean = clean[7:]
|
| 118 |
+
if clean.startswith("```"):
|
| 119 |
+
clean = clean[3:]
|
| 120 |
+
if clean.endswith("```"):
|
| 121 |
+
clean = clean[:-3]
|
| 122 |
+
return json.loads(clean.strip())
|
| 123 |
+
except Exception as e:
|
| 124 |
+
return {
|
| 125 |
+
"recommended_action": "Failed to parse LLM response",
|
| 126 |
+
"rule_applied": "none",
|
| 127 |
+
"evidence": [],
|
| 128 |
+
"skill_matched": "none",
|
| 129 |
+
"confidence": 0.0,
|
| 130 |
+
"retrieval_scores": [],
|
| 131 |
+
"reasoning": f"JSON parse error: {e}. Raw: {raw[:500]}"
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _error_response(msg: str) -> dict:
|
| 136 |
+
return {
|
| 137 |
+
"recommended_action": msg,
|
| 138 |
+
"rule_applied": "none",
|
| 139 |
+
"evidence": [],
|
| 140 |
+
"skill_matched": "none",
|
| 141 |
+
"confidence": 0.0,
|
| 142 |
+
"retrieval_scores": [],
|
| 143 |
+
"reasoning": msg
|
| 144 |
+
}
|
backend/db/schema.sql
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Run these in Supabase SQL editor before starting
|
| 2 |
+
|
| 3 |
+
CREATE TABLE companies (
|
| 4 |
+
id TEXT PRIMARY KEY,
|
| 5 |
+
name TEXT NOT NULL,
|
| 6 |
+
created_at TIMESTAMPTZ DEFAULT now()
|
| 7 |
+
);
|
| 8 |
+
|
| 9 |
+
INSERT INTO companies VALUES ('rivanly-inc', 'Rivanly Inc.', now());
|
| 10 |
+
|
| 11 |
+
CREATE TABLE skills_files (
|
| 12 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 13 |
+
company_id TEXT REFERENCES companies(id),
|
| 14 |
+
version TEXT NOT NULL,
|
| 15 |
+
brain_json JSONB NOT NULL,
|
| 16 |
+
source_hashes JSONB NOT NULL,
|
| 17 |
+
compiled_at TIMESTAMPTZ DEFAULT now(),
|
| 18 |
+
is_current BOOLEAN DEFAULT false
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
CREATE UNIQUE INDEX idx_skills_files_current ON skills_files(company_id) WHERE is_current = true;
|
| 22 |
+
|
| 23 |
+
CREATE TABLE skills (
|
| 24 |
+
id TEXT NOT NULL,
|
| 25 |
+
company_id TEXT REFERENCES companies(id),
|
| 26 |
+
skills_file_id UUID REFERENCES skills_files(id),
|
| 27 |
+
name TEXT NOT NULL,
|
| 28 |
+
domain TEXT NOT NULL,
|
| 29 |
+
version TEXT NOT NULL,
|
| 30 |
+
confidence FLOAT NOT NULL,
|
| 31 |
+
stale BOOLEAN DEFAULT false,
|
| 32 |
+
review_required BOOLEAN DEFAULT false,
|
| 33 |
+
skill_json JSONB NOT NULL,
|
| 34 |
+
PRIMARY KEY (id, company_id, skills_file_id)
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
CREATE TABLE source_files (
|
| 38 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 39 |
+
company_id TEXT REFERENCES companies(id),
|
| 40 |
+
filename TEXT NOT NULL,
|
| 41 |
+
sha256 TEXT NOT NULL,
|
| 42 |
+
storage_path TEXT NOT NULL,
|
| 43 |
+
uploaded_at TIMESTAMPTZ DEFAULT now()
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
CREATE TABLE compile_runs (
|
| 47 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 48 |
+
company_id TEXT REFERENCES companies(id),
|
| 49 |
+
status TEXT NOT NULL CHECK (status IN ('started','running','complete','error')),
|
| 50 |
+
started_at TIMESTAMPTZ DEFAULT now(),
|
| 51 |
+
completed_at TIMESTAMPTZ,
|
| 52 |
+
duration_ms INTEGER,
|
| 53 |
+
result_version TEXT,
|
| 54 |
+
error_detail TEXT
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
CREATE INDEX idx_skills_files_company ON skills_files(company_id, compiled_at DESC);
|
| 58 |
+
CREATE INDEX idx_skills_company ON skills(company_id);
|
backend/db/supabase.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client, Client
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 8 |
+
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
|
| 9 |
+
|
| 10 |
+
if SUPABASE_URL and SUPABASE_KEY:
|
| 11 |
+
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 12 |
+
else:
|
| 13 |
+
# We allow the app to start without Supabase for local testing if needed,
|
| 14 |
+
# but actual DB calls will fail if not provided.
|
| 15 |
+
supabase = None
|
| 16 |
+
|
| 17 |
+
def get_client():
|
| 18 |
+
return supabase
|
| 19 |
+
|
| 20 |
+
def get_current_brain(company_id: str):
|
| 21 |
+
if not supabase: return None
|
| 22 |
+
res = supabase.table("skills_files").select("*").eq("company_id", company_id).eq("is_current", True).execute()
|
| 23 |
+
if res.data:
|
| 24 |
+
return res.data[0]
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
def save_skills_file(data: dict):
|
| 28 |
+
if not supabase: return None
|
| 29 |
+
res = supabase.table("skills_files").insert(data).execute()
|
| 30 |
+
return res.data
|
| 31 |
+
|
| 32 |
+
def save_compile_run(data: dict):
|
| 33 |
+
if not supabase: return None
|
| 34 |
+
res = supabase.table("compile_runs").insert(data).execute()
|
| 35 |
+
return res.data
|
| 36 |
+
|
| 37 |
+
def update_compile_run(run_id: str, data: dict):
|
| 38 |
+
if not supabase: return None
|
| 39 |
+
res = supabase.table("compile_runs").update(data).eq("id", run_id).execute()
|
| 40 |
+
return res.data
|
| 41 |
+
|
| 42 |
+
def get_source_hashes(company_id: str):
|
| 43 |
+
if not supabase: return {}
|
| 44 |
+
# Get the latest current brain
|
| 45 |
+
brain = get_current_brain(company_id)
|
| 46 |
+
if brain:
|
| 47 |
+
return brain.get("source_hashes", {})
|
| 48 |
+
return {}
|
| 49 |
+
|
| 50 |
+
def save_source_file(data: dict):
|
| 51 |
+
if not supabase: return None
|
| 52 |
+
res = supabase.table("source_files").insert(data).execute()
|
| 53 |
+
return res.data
|
| 54 |
+
|
| 55 |
+
def get_skills_by_brain_id(brain_id: str):
|
| 56 |
+
if not supabase: return []
|
| 57 |
+
res = supabase.table("skills").select("*").eq("skills_file_id", brain_id).execute()
|
| 58 |
+
return res.data
|
| 59 |
+
|
| 60 |
+
def insert_skills(data: list):
|
| 61 |
+
if not supabase: return None
|
| 62 |
+
res = supabase.table("skills").insert(data).execute()
|
| 63 |
+
return res.data
|
backend/graph/graph.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.graph import StateGraph, END
|
| 2 |
+
from backend.graph.state import BrainState
|
| 3 |
+
from backend.graph.nodes.load_and_chunk import load_and_chunk
|
| 4 |
+
from backend.graph.nodes.cluster_evidence import cluster_evidence
|
| 5 |
+
from backend.graph.nodes.synthesize_skills import synthesize_skills
|
| 6 |
+
from backend.graph.nodes.quality_normalize import quality_normalize
|
| 7 |
+
from backend.graph.nodes.write_brain import write_brain
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def build_compilation_graph() -> StateGraph:
|
| 11 |
+
"""
|
| 12 |
+
Linear 5-node pipeline:
|
| 13 |
+
load_and_chunk → cluster_evidence → synthesize_skills → quality_normalize → write_brain
|
| 14 |
+
"""
|
| 15 |
+
workflow = StateGraph(BrainState)
|
| 16 |
+
|
| 17 |
+
workflow.add_node("load_and_chunk", load_and_chunk)
|
| 18 |
+
workflow.add_node("cluster_evidence", cluster_evidence)
|
| 19 |
+
workflow.add_node("synthesize_skills", synthesize_skills)
|
| 20 |
+
workflow.add_node("quality_normalize", quality_normalize)
|
| 21 |
+
workflow.add_node("write_brain", write_brain)
|
| 22 |
+
|
| 23 |
+
workflow.set_entry_point("load_and_chunk")
|
| 24 |
+
workflow.add_edge("load_and_chunk", "cluster_evidence")
|
| 25 |
+
workflow.add_edge("cluster_evidence", "synthesize_skills")
|
| 26 |
+
workflow.add_edge("synthesize_skills", "quality_normalize")
|
| 27 |
+
workflow.add_edge("quality_normalize", "write_brain")
|
| 28 |
+
workflow.add_edge("write_brain", END)
|
| 29 |
+
|
| 30 |
+
return workflow.compile()
|
backend/graph/nodes/cluster_evidence.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Node 2: Embed all chunks and cluster them by domain using the LLM.
|
| 3 |
+
Emits SSE stage: EMBEDDING
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
from backend.graph.state import BrainState
|
| 7 |
+
from backend.llm import llm_call, get_embeddings
|
| 8 |
+
from backend.sse import emit
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def cluster_evidence(state: BrainState) -> dict:
|
| 12 |
+
job_id = state["job_id"]
|
| 13 |
+
chunks = state.get("chunks", [])
|
| 14 |
+
|
| 15 |
+
print(f"[{job_id}] Node cluster_evidence started with {len(chunks)} chunks")
|
| 16 |
+
|
| 17 |
+
if not chunks:
|
| 18 |
+
await emit(job_id, "stage", {"name": "EMBEDDING", "detail": "No chunks to embed"})
|
| 19 |
+
return {"clusters": {"domains": {}}}
|
| 20 |
+
|
| 21 |
+
await emit(job_id, "stage", {"name": "EMBEDDING", "detail": f"Embedding {len(chunks)} chunks"})
|
| 22 |
+
|
| 23 |
+
# Build a numbered summary of each chunk for the LLM
|
| 24 |
+
summaries = []
|
| 25 |
+
for i, c in enumerate(chunks):
|
| 26 |
+
# Truncate long chunks for the categorization prompt
|
| 27 |
+
preview = c["text"][:300].replace("\n", " ")
|
| 28 |
+
summaries.append(f"[{i}] ({c['source_file']}) {preview}")
|
| 29 |
+
|
| 30 |
+
chunk_list_text = "\n".join(summaries)
|
| 31 |
+
|
| 32 |
+
prompt = """You are an operations analyst. Below is a numbered list of text chunks extracted from a company's internal documents (SOPs, Slack messages, support tickets).
|
| 33 |
+
|
| 34 |
+
Categorize each chunk into an operational domain. Use clear domain names like:
|
| 35 |
+
"Customer Support", "Engineering", "Sales", "Human Resources", "Finance", "Operations", etc.
|
| 36 |
+
|
| 37 |
+
Return ONLY a valid JSON object mapping domain names to arrays of chunk indices.
|
| 38 |
+
Example: {"Customer Support": [0, 3, 5], "Engineering": [1, 2], "Sales": [4]}
|
| 39 |
+
|
| 40 |
+
Every chunk index must appear exactly once. Do not skip any."""
|
| 41 |
+
|
| 42 |
+
response_str = await llm_call(prompt, chunk_list_text)
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
clean = response_str.strip()
|
| 46 |
+
if clean.startswith("```json"):
|
| 47 |
+
clean = clean[7:]
|
| 48 |
+
if clean.startswith("```"):
|
| 49 |
+
clean = clean[3:]
|
| 50 |
+
if clean.endswith("```"):
|
| 51 |
+
clean = clean[:-3]
|
| 52 |
+
domains = json.loads(clean.strip())
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f"[cluster_evidence] Failed to parse LLM clustering: {e}")
|
| 55 |
+
# Fallback: put all chunks in one cluster
|
| 56 |
+
domains = {"General": list(range(len(chunks)))}
|
| 57 |
+
|
| 58 |
+
await emit(job_id, "stage", {
|
| 59 |
+
"name": "EMBEDDING_DONE",
|
| 60 |
+
"detail": f"Clustered into {len(domains)} domains: {list(domains.keys())}",
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
print(f"[{job_id}] Node cluster_evidence finished with {len(domains)} domains")
|
| 64 |
+
return {"clusters": {"domains": domains}}
|
backend/graph/nodes/load_and_chunk.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Node 1: Load source files from disk and chunk them.
|
| 3 |
+
Emits SSE stages: LOADING_DOCS, CHUNKING
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
import hashlib
|
| 8 |
+
import time
|
| 9 |
+
from backend.graph.state import BrainState
|
| 10 |
+
from backend.sse import emit
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def load_and_chunk(state: BrainState) -> dict:
|
| 14 |
+
company_id = state["company_id"]
|
| 15 |
+
job_id = state["job_id"]
|
| 16 |
+
|
| 17 |
+
print(f"[{job_id}] Node load_and_chunk started")
|
| 18 |
+
await emit(job_id, "stage", {"name": "LOADING_DOCS", "detail": f"Reading sources for {company_id}"})
|
| 19 |
+
|
| 20 |
+
# Read files from the company-specific directory
|
| 21 |
+
# __file__ is backend/graph/nodes/load_and_chunk.py
|
| 22 |
+
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 23 |
+
sources_dir = os.path.join(base, "data", "sources", company_id)
|
| 24 |
+
|
| 25 |
+
if not os.path.isdir(sources_dir):
|
| 26 |
+
await emit(job_id, "pipeline_error", {"error": f"No source directory found: data/sources/{company_id}/"})
|
| 27 |
+
print(f"[{job_id}] Node load_and_chunk failed (Missing dir: {sources_dir})")
|
| 28 |
+
return {"errors": [f"Missing directory: {sources_dir}"], "source_files": [], "chunks": []}
|
| 29 |
+
|
| 30 |
+
source_files = []
|
| 31 |
+
for filename in sorted(os.listdir(sources_dir)):
|
| 32 |
+
filepath = os.path.join(sources_dir, filename)
|
| 33 |
+
if not os.path.isfile(filepath):
|
| 34 |
+
continue
|
| 35 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 36 |
+
content = f.read()
|
| 37 |
+
doc_type = _detect_type(filename)
|
| 38 |
+
source_files.append({
|
| 39 |
+
"filename": filename,
|
| 40 |
+
"content": content,
|
| 41 |
+
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
| 42 |
+
"doc_type": doc_type,
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
await emit(job_id, "stage", {
|
| 46 |
+
"name": "CHUNKING",
|
| 47 |
+
"detail": f"Splitting {len(source_files)} files into chunks",
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
chunks = []
|
| 51 |
+
for sf in source_files:
|
| 52 |
+
if sf["doc_type"] == "notion_md":
|
| 53 |
+
chunks.extend(_chunk_markdown(sf))
|
| 54 |
+
elif sf["doc_type"] == "slack_json":
|
| 55 |
+
chunks.extend(_chunk_slack(sf))
|
| 56 |
+
elif sf["doc_type"] == "tickets_json":
|
| 57 |
+
chunks.extend(_chunk_tickets(sf))
|
| 58 |
+
else:
|
| 59 |
+
# Treat unknown as plain text
|
| 60 |
+
chunks.append({
|
| 61 |
+
"text": sf["content"],
|
| 62 |
+
"source_file": sf["filename"],
|
| 63 |
+
"chunk_index": 0,
|
| 64 |
+
"doc_type": sf["doc_type"],
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
await emit(job_id, "stage", {
|
| 68 |
+
"name": "CHUNKING_DONE",
|
| 69 |
+
"detail": f"Produced {len(chunks)} chunks from {len(source_files)} files",
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
print(f"[{job_id}] Node load_and_chunk finished (chunks: {len(chunks)})")
|
| 73 |
+
return {"source_files": source_files, "chunks": chunks}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# --- Helpers ---
|
| 77 |
+
|
| 78 |
+
def _detect_type(filename: str) -> str:
|
| 79 |
+
fn = filename.lower()
|
| 80 |
+
if fn.endswith(".json"):
|
| 81 |
+
if "slack" in fn:
|
| 82 |
+
return "slack_json"
|
| 83 |
+
if "ticket" in fn or "zendesk" in fn:
|
| 84 |
+
return "tickets_json"
|
| 85 |
+
return "json"
|
| 86 |
+
if fn.endswith(".md"):
|
| 87 |
+
return "notion_md"
|
| 88 |
+
return "unknown"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _chunk_markdown(sf: dict) -> list:
|
| 92 |
+
"""Split a markdown file by ## headers. Each section is a chunk."""
|
| 93 |
+
content = sf["content"]
|
| 94 |
+
sections = []
|
| 95 |
+
current_header = "Introduction"
|
| 96 |
+
current_body = []
|
| 97 |
+
|
| 98 |
+
for line in content.split("\n"):
|
| 99 |
+
if line.startswith("## "):
|
| 100 |
+
if current_body:
|
| 101 |
+
sections.append((current_header, "\n".join(current_body).strip()))
|
| 102 |
+
current_header = line.lstrip("# ").strip()
|
| 103 |
+
current_body = []
|
| 104 |
+
else:
|
| 105 |
+
current_body.append(line)
|
| 106 |
+
|
| 107 |
+
if current_body:
|
| 108 |
+
sections.append((current_header, "\n".join(current_body).strip()))
|
| 109 |
+
|
| 110 |
+
chunks = []
|
| 111 |
+
for i, (header, body) in enumerate(sections):
|
| 112 |
+
if not body:
|
| 113 |
+
continue
|
| 114 |
+
chunks.append({
|
| 115 |
+
"text": f"[{header}] {body}",
|
| 116 |
+
"source_file": sf["filename"],
|
| 117 |
+
"chunk_index": i,
|
| 118 |
+
"doc_type": "notion_md",
|
| 119 |
+
"section_header": header,
|
| 120 |
+
})
|
| 121 |
+
return chunks
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _chunk_slack(sf: dict) -> list:
|
| 125 |
+
"""Each Slack message is one chunk."""
|
| 126 |
+
try:
|
| 127 |
+
messages = json.loads(sf["content"])
|
| 128 |
+
except json.JSONDecodeError:
|
| 129 |
+
return []
|
| 130 |
+
chunks = []
|
| 131 |
+
for i, msg in enumerate(messages):
|
| 132 |
+
text = msg.get("text", "")
|
| 133 |
+
if not text:
|
| 134 |
+
continue
|
| 135 |
+
user = msg.get("user", "unknown")
|
| 136 |
+
channel = msg.get("channel", "unknown")
|
| 137 |
+
chunks.append({
|
| 138 |
+
"text": f"[Slack #{channel} @{user}] {text}",
|
| 139 |
+
"source_file": sf["filename"],
|
| 140 |
+
"chunk_index": i,
|
| 141 |
+
"doc_type": "slack_json",
|
| 142 |
+
})
|
| 143 |
+
return chunks
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _chunk_tickets(sf: dict) -> list:
|
| 147 |
+
"""Each ticket is one chunk."""
|
| 148 |
+
try:
|
| 149 |
+
tickets = json.loads(sf["content"])
|
| 150 |
+
except json.JSONDecodeError:
|
| 151 |
+
return []
|
| 152 |
+
chunks = []
|
| 153 |
+
for i, tkt in enumerate(tickets):
|
| 154 |
+
parts = []
|
| 155 |
+
if tkt.get("subject"):
|
| 156 |
+
parts.append(f"Subject: {tkt['subject']}")
|
| 157 |
+
if tkt.get("description"):
|
| 158 |
+
parts.append(f"Description: {tkt['description']}")
|
| 159 |
+
if tkt.get("resolution"):
|
| 160 |
+
parts.append(f"Resolution: {tkt['resolution']}")
|
| 161 |
+
if tkt.get("priority"):
|
| 162 |
+
parts.append(f"Priority: {tkt['priority']}")
|
| 163 |
+
if tkt.get("customer_plan"):
|
| 164 |
+
parts.append(f"Plan: {tkt['customer_plan']}")
|
| 165 |
+
text = " | ".join(parts)
|
| 166 |
+
if not text:
|
| 167 |
+
continue
|
| 168 |
+
chunks.append({
|
| 169 |
+
"text": f"[Zendesk Ticket] {text}",
|
| 170 |
+
"source_file": sf["filename"],
|
| 171 |
+
"chunk_index": i,
|
| 172 |
+
"doc_type": "tickets_json",
|
| 173 |
+
})
|
| 174 |
+
return chunks
|
backend/graph/nodes/quality_normalize.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Node 4: De-duplicate skills, resolve conflicts, score confidence, enforce schema.
|
| 3 |
+
Emits SSE stage: QUALITY_CHECK
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
from backend.graph.state import BrainState
|
| 7 |
+
from backend.llm import llm_call
|
| 8 |
+
from backend.sse import emit
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def quality_normalize(state: BrainState) -> dict:
|
| 12 |
+
job_id = state["job_id"]
|
| 13 |
+
raw_skills = state.get("raw_skills", [])
|
| 14 |
+
|
| 15 |
+
print(f"[{job_id}] Node quality_normalize started with {len(raw_skills)} raw skills")
|
| 16 |
+
|
| 17 |
+
if not raw_skills:
|
| 18 |
+
await emit(job_id, "stage", {"name": "QUALITY_CHECK", "detail": "No skills to normalize"})
|
| 19 |
+
print(f"[{job_id}] Node quality_normalize finished (0 skills)")
|
| 20 |
+
return {"skills_file": {"skills": []}}
|
| 21 |
+
|
| 22 |
+
await emit(job_id, "stage", {
|
| 23 |
+
"name": "QUALITY_CHECK",
|
| 24 |
+
"detail": f"Normalizing {len(raw_skills)} raw skills",
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
prompt = """You are a quality assurance agent for an operational skills file.
|
| 28 |
+
|
| 29 |
+
Below is a raw list of skills extracted from company documents. Your job:
|
| 30 |
+
|
| 31 |
+
1. DEDUPLICATE: merge skills that describe the same rule (keep the most complete version).
|
| 32 |
+
2. RESOLVE CONFLICTS: if two skills contradict, keep both but note the conflict in the rationale. Prefer observed behavior (from Slack/tickets) over stated policy (from SOPs) when they conflict.
|
| 33 |
+
3. SCORE CONFIDENCE (0.0 to 1.0) for each skill based on:
|
| 34 |
+
- 0.9–1.0: multiple confirming sources, clear unambiguous rule
|
| 35 |
+
- 0.7–0.89: single strong source or multiple weak sources
|
| 36 |
+
- 0.5–0.69: only one source, or some ambiguity
|
| 37 |
+
- 0.3–0.49: weak evidence or significant ambiguity
|
| 38 |
+
- < 0.3: speculative or poorly supported
|
| 39 |
+
4. ENFORCE SCHEMA: every skill must have: id, category, rule, rationale, evidence (array), confidence (float).
|
| 40 |
+
|
| 41 |
+
Return ONLY a JSON object:
|
| 42 |
+
{
|
| 43 |
+
"skills": [
|
| 44 |
+
{
|
| 45 |
+
"id": "skill_slug",
|
| 46 |
+
"category": "Domain Name",
|
| 47 |
+
"rule": "The specific rule text",
|
| 48 |
+
"rationale": "Why this rule exists",
|
| 49 |
+
"evidence": ["source reference 1", "source reference 2"],
|
| 50 |
+
"confidence": 0.85
|
| 51 |
+
}
|
| 52 |
+
]
|
| 53 |
+
}"""
|
| 54 |
+
|
| 55 |
+
skills_text = json.dumps(raw_skills, indent=2)
|
| 56 |
+
print(f"[{job_id}] Requesting quality normalization...")
|
| 57 |
+
response_str = await llm_call(prompt, skills_text, max_tokens=8192)
|
| 58 |
+
print(f"[{job_id}] Received quality normalization response")
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
clean = response_str.strip()
|
| 62 |
+
if clean.startswith("```json"):
|
| 63 |
+
clean = clean[7:]
|
| 64 |
+
if clean.startswith("```"):
|
| 65 |
+
clean = clean[3:]
|
| 66 |
+
if clean.endswith("```"):
|
| 67 |
+
clean = clean[:-3]
|
| 68 |
+
data = json.loads(clean.strip())
|
| 69 |
+
final_skills = data.get("skills", raw_skills)
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"[{job_id}] [quality_normalize] Parse error: {e}")
|
| 72 |
+
# Fallback: use raw skills with default confidence
|
| 73 |
+
final_skills = raw_skills
|
| 74 |
+
for sk in final_skills:
|
| 75 |
+
sk.setdefault("confidence", 0.5)
|
| 76 |
+
|
| 77 |
+
await emit(job_id, "stage", {
|
| 78 |
+
"name": "QUALITY_CHECK_DONE",
|
| 79 |
+
"detail": f"Final skills count: {len(final_skills)} (from {len(raw_skills)} raw)",
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
print(f"[{job_id}] Node quality_normalize finished (final skills: {len(final_skills)})")
|
| 83 |
+
return {"skills_file": {"skills": final_skills}}
|
backend/graph/nodes/synthesize_skills.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Node 3: For each domain cluster, call vLLM to synthesize structured skills.
|
| 3 |
+
Emits SSE stage: SYNTHESIZING_SKILLS
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import uuid
|
| 7 |
+
from backend.graph.state import BrainState
|
| 8 |
+
from backend.llm import llm_call
|
| 9 |
+
from backend.sse import emit
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def synthesize_skills(state: BrainState) -> dict:
|
| 13 |
+
job_id = state["job_id"]
|
| 14 |
+
chunks = state.get("chunks", [])
|
| 15 |
+
clusters = state.get("clusters", {})
|
| 16 |
+
domains = clusters.get("domains", {})
|
| 17 |
+
|
| 18 |
+
print(f"[{job_id}] Node synthesize_skills started with {len(domains)} domains")
|
| 19 |
+
|
| 20 |
+
if not domains:
|
| 21 |
+
await emit(job_id, "stage", {"name": "SYNTHESIZING_SKILLS", "detail": "No clusters to synthesize"})
|
| 22 |
+
print(f"[{job_id}] Node synthesize_skills finished (0 domains)")
|
| 23 |
+
return {"raw_skills": []}
|
| 24 |
+
|
| 25 |
+
await emit(job_id, "stage", {
|
| 26 |
+
"name": "SYNTHESIZING_SKILLS",
|
| 27 |
+
"detail": f"Synthesizing skills for {len(domains)} domains",
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
all_skills = []
|
| 31 |
+
|
| 32 |
+
for domain_name, chunk_indices in domains.items():
|
| 33 |
+
# Gather the actual chunk texts for this domain
|
| 34 |
+
domain_chunks = []
|
| 35 |
+
for idx in chunk_indices:
|
| 36 |
+
if 0 <= idx < len(chunks):
|
| 37 |
+
domain_chunks.append(chunks[idx])
|
| 38 |
+
|
| 39 |
+
if not domain_chunks:
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
chunk_text = "\n\n".join([c["text"] for c in domain_chunks])
|
| 43 |
+
source_files = list(set(c["source_file"] for c in domain_chunks))
|
| 44 |
+
|
| 45 |
+
prompt = f"""You are a Principal Operations Architect analyzing the "{domain_name}" domain.
|
| 46 |
+
|
| 47 |
+
Below are real excerpts from a company's internal documents (SOPs, Slack messages, support tickets) related to {domain_name}.
|
| 48 |
+
|
| 49 |
+
Your job: extract every distinct operational rule, policy, process, or decision pattern you can find.
|
| 50 |
+
|
| 51 |
+
For EACH skill, provide:
|
| 52 |
+
- id: a unique identifier (use a short slug like "refund_loyal_customer")
|
| 53 |
+
- category: "{domain_name}"
|
| 54 |
+
- rule: the specific, actionable rule or process (be precise — include thresholds, timeframes, approvals)
|
| 55 |
+
- rationale: why this rule exists (based on the evidence)
|
| 56 |
+
- evidence: array of specific quotes or references from the source chunks that support this rule
|
| 57 |
+
- source_files: which files this came from
|
| 58 |
+
|
| 59 |
+
Rules for quality:
|
| 60 |
+
- Extract what the documents ACTUALLY say, not what you assume.
|
| 61 |
+
- If there are contradictions (e.g., SOP says X but Slack shows Y), note BOTH and state which takes precedence in practice.
|
| 62 |
+
- Do NOT invent rules that aren't supported by the text below.
|
| 63 |
+
- Each rule should be specific enough that a human could follow it without additional context.
|
| 64 |
+
|
| 65 |
+
Respond with ONLY a JSON object:
|
| 66 |
+
{{
|
| 67 |
+
"skills": [
|
| 68 |
+
{{
|
| 69 |
+
"id": "refund_loyal_customer",
|
| 70 |
+
"category": "{domain_name}",
|
| 71 |
+
"rule": "Approve refunds up to 45 days for customers with >2 years tenure",
|
| 72 |
+
"rationale": "Exception applied over standard 30-day limit for loyal customers",
|
| 73 |
+
"evidence": ["slack_export_support.json: Mike approved 45-day refund for Acme Corp"],
|
| 74 |
+
"source_files": ["slack_export_support.json", "notion_refund_sop.md"]
|
| 75 |
+
}}
|
| 76 |
+
]
|
| 77 |
+
}}"""
|
| 78 |
+
|
| 79 |
+
print(f"[{job_id}] Requesting skills for domain '{domain_name}'...")
|
| 80 |
+
response_str = await llm_call(prompt, chunk_text)
|
| 81 |
+
print(f"[{job_id}] Received skills response for domain '{domain_name}'")
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
clean = response_str.strip()
|
| 85 |
+
if clean.startswith("```json"):
|
| 86 |
+
clean = clean[7:]
|
| 87 |
+
if clean.startswith("```"):
|
| 88 |
+
clean = clean[3:]
|
| 89 |
+
if clean.endswith("```"):
|
| 90 |
+
clean = clean[:-3]
|
| 91 |
+
data = json.loads(clean.strip())
|
| 92 |
+
domain_skills = data.get("skills", [])
|
| 93 |
+
except Exception as e:
|
| 94 |
+
print(f"[{job_id}] [synthesize_skills] Parse error for {domain_name}: {e}")
|
| 95 |
+
domain_skills = []
|
| 96 |
+
|
| 97 |
+
# Ensure every skill has an id
|
| 98 |
+
for sk in domain_skills:
|
| 99 |
+
if not sk.get("id"):
|
| 100 |
+
sk["id"] = str(uuid.uuid4())[:8]
|
| 101 |
+
sk["category"] = domain_name # ensure consistency
|
| 102 |
+
|
| 103 |
+
all_skills.extend(domain_skills)
|
| 104 |
+
|
| 105 |
+
await emit(job_id, "stage", {
|
| 106 |
+
"name": "SYNTHESIZING_SKILLS",
|
| 107 |
+
"detail": f"{domain_name}: extracted {len(domain_skills)} skills",
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
print(f"[{job_id}] Node synthesize_skills finished (extracted {len(all_skills)} skills overall)")
|
| 111 |
+
return {"raw_skills": all_skills}
|
backend/graph/nodes/write_brain.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Node 5: Write the final skills file to the database.
|
| 3 |
+
Emits SSE stage: WRITING_DB, then pipeline_complete.
|
| 4 |
+
"""
|
| 5 |
+
import time
|
| 6 |
+
import json
|
| 7 |
+
import uuid
|
| 8 |
+
import datetime
|
| 9 |
+
from backend.graph.state import BrainState
|
| 10 |
+
from backend.db.supabase import get_client
|
| 11 |
+
from backend.sse import emit
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def write_brain(state: BrainState) -> dict:
|
| 15 |
+
job_id = state.get("job_id")
|
| 16 |
+
company_id = state.get("company_id")
|
| 17 |
+
skills_file = state.get("skills_file", {})
|
| 18 |
+
skills = skills_file.get("skills", [])
|
| 19 |
+
start_time = state.get("start_time", time.time())
|
| 20 |
+
duration_ms = int((time.time() - start_time) * 1000)
|
| 21 |
+
|
| 22 |
+
print(f"[{job_id}] Node write_brain started for {company_id}")
|
| 23 |
+
|
| 24 |
+
await emit(job_id, "stage", {"name": "WRITING_DB", "detail": f"Persisting {len(skills)} skills"})
|
| 25 |
+
|
| 26 |
+
db = get_client()
|
| 27 |
+
if not db:
|
| 28 |
+
await emit(job_id, "pipeline_error", {"error": "Database connection failed"})
|
| 29 |
+
print(f"[{job_id}] Node write_brain failed (no DB client)")
|
| 30 |
+
return {"errors": ["DB connection failed in write_brain"]}
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
| 34 |
+
version_str = f"v_{int(time.time())}"
|
| 35 |
+
|
| 36 |
+
source_hashes = {}
|
| 37 |
+
for f in state.get("source_files", []):
|
| 38 |
+
if "filename" in f and "sha256" in f:
|
| 39 |
+
source_hashes[f["filename"]] = f["sha256"]
|
| 40 |
+
|
| 41 |
+
# Mark previous brain as not current
|
| 42 |
+
db.table("skills_files").update(
|
| 43 |
+
{"is_current": False}
|
| 44 |
+
).eq("company_id", company_id).eq("is_current", True).execute()
|
| 45 |
+
|
| 46 |
+
# Insert new brain
|
| 47 |
+
sf_res = db.table("skills_files").insert({
|
| 48 |
+
"company_id": company_id,
|
| 49 |
+
"version": version_str,
|
| 50 |
+
"brain_json": skills_file,
|
| 51 |
+
"source_hashes": source_hashes,
|
| 52 |
+
"is_current": True,
|
| 53 |
+
}).execute()
|
| 54 |
+
|
| 55 |
+
sf_id = sf_res.data[0]["id"]
|
| 56 |
+
|
| 57 |
+
# Insert individual skills
|
| 58 |
+
for skill in skills:
|
| 59 |
+
db.table("skills").insert({
|
| 60 |
+
"id": skill.get("id", str(uuid.uuid4())[:8]),
|
| 61 |
+
"company_id": company_id,
|
| 62 |
+
"skills_file_id": sf_id,
|
| 63 |
+
"name": skill.get("rule", "Unknown")[:200],
|
| 64 |
+
"domain": skill.get("category", "general"),
|
| 65 |
+
"version": version_str,
|
| 66 |
+
"confidence": float(skill.get("confidence", 0.5)),
|
| 67 |
+
"skill_json": skill,
|
| 68 |
+
}).execute()
|
| 69 |
+
|
| 70 |
+
# Update compile run
|
| 71 |
+
db.table("compile_runs").update({
|
| 72 |
+
"status": "complete",
|
| 73 |
+
"completed_at": now_iso,
|
| 74 |
+
"duration_ms": duration_ms,
|
| 75 |
+
"result_version": version_str,
|
| 76 |
+
}).eq("id", job_id).execute()
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f"[{job_id}] [write_brain] DB Error: {e}")
|
| 80 |
+
await emit(job_id, "pipeline_error", {"error": str(e)})
|
| 81 |
+
return {"errors": [f"write_brain DB error: {e}"]}
|
| 82 |
+
|
| 83 |
+
await emit(job_id, "stage", {
|
| 84 |
+
"name": "DONE",
|
| 85 |
+
"detail": f"Brain {version_str} written: {len(skills)} skills, {len(source_hashes)} sources, {duration_ms}ms",
|
| 86 |
+
})
|
| 87 |
+
await emit(job_id, "pipeline_complete", {
|
| 88 |
+
"status": "success",
|
| 89 |
+
"version": version_str,
|
| 90 |
+
"skills_count": len(skills),
|
| 91 |
+
"source_count": len(source_hashes),
|
| 92 |
+
"duration_ms": duration_ms,
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
print(f"[{job_id}] Node write_brain finished successfully (version: {version_str})")
|
| 96 |
+
return {}
|
backend/graph/state.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import TypedDict, Annotated, List, Dict, Any
|
| 2 |
+
import operator
|
| 3 |
+
|
| 4 |
+
class BrainState(TypedDict):
|
| 5 |
+
company_id: str
|
| 6 |
+
job_id: str
|
| 7 |
+
source_files: List[Dict[str, Any]] # [{filename, content, sha256, doc_type}]
|
| 8 |
+
chunks: List[Dict[str, Any]] # [{text, source_file, chunk_index, doc_type}]
|
| 9 |
+
clusters: Dict[str, Any] # {domains: {domain_name: [chunk_indices]}}
|
| 10 |
+
raw_skills: List[Dict[str, Any]] # skills before quality pass
|
| 11 |
+
skills_file: Dict[str, Any] # final {skills: [...]}
|
| 12 |
+
brain_version: str
|
| 13 |
+
start_time: float
|
| 14 |
+
errors: Annotated[List[str], operator.add]
|
backend/llm.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
from openai import AsyncOpenAI
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
VLLM_BASE_URL = os.getenv("VLLM_BASE_URL", "http://localhost:8000/v1")
|
| 10 |
+
MODEL_NAME = "RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic"
|
| 11 |
+
|
| 12 |
+
llm = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed", timeout=120.0)
|
| 13 |
+
|
| 14 |
+
# --- Embedding model (local, fast, centralized here) ---
|
| 15 |
+
_embedding_model = None
|
| 16 |
+
|
| 17 |
+
def _get_embedding_model():
|
| 18 |
+
global _embedding_model
|
| 19 |
+
if _embedding_model is None:
|
| 20 |
+
from sentence_transformers import SentenceTransformer
|
| 21 |
+
_embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 22 |
+
return _embedding_model
|
| 23 |
+
|
| 24 |
+
def get_embedding(text: str) -> list:
|
| 25 |
+
"""Return a single embedding vector as a Python list."""
|
| 26 |
+
model = _get_embedding_model()
|
| 27 |
+
return model.encode(text).tolist()
|
| 28 |
+
|
| 29 |
+
def get_embeddings(texts: list) -> list:
|
| 30 |
+
"""Return a list of embedding vectors."""
|
| 31 |
+
model = _get_embedding_model()
|
| 32 |
+
return [v.tolist() for v in model.encode(texts)]
|
| 33 |
+
|
| 34 |
+
def cosine_similarity(v1, v2) -> float:
|
| 35 |
+
"""Cosine similarity between two vectors."""
|
| 36 |
+
a, b = np.array(v1), np.array(v2)
|
| 37 |
+
denom = np.linalg.norm(a) * np.linalg.norm(b)
|
| 38 |
+
if denom == 0:
|
| 39 |
+
return 0.0
|
| 40 |
+
return float(np.dot(a, b) / denom)
|
| 41 |
+
|
| 42 |
+
async def check_vllm_health() -> dict:
|
| 43 |
+
"""Ping the vLLM /v1/models endpoint. Returns status dict."""
|
| 44 |
+
try:
|
| 45 |
+
response = await llm.models.list()
|
| 46 |
+
models = [m.id for m in response.data]
|
| 47 |
+
return {"healthy": True, "models": models, "url": VLLM_BASE_URL}
|
| 48 |
+
except Exception as e:
|
| 49 |
+
return {"healthy": False, "error": str(e), "url": VLLM_BASE_URL}
|
| 50 |
+
|
| 51 |
+
async def llm_call(system_prompt: str, user_content: str, temperature: float = 0.1, max_tokens: int = 4096) -> str:
|
| 52 |
+
"""Single centralized LLM call through vLLM. Raises on failure."""
|
| 53 |
+
try:
|
| 54 |
+
response = await llm.chat.completions.create(
|
| 55 |
+
model=MODEL_NAME,
|
| 56 |
+
messages=[
|
| 57 |
+
{"role": "system", "content": system_prompt},
|
| 58 |
+
{"role": "user", "content": user_content}
|
| 59 |
+
],
|
| 60 |
+
temperature=temperature,
|
| 61 |
+
max_tokens=max_tokens
|
| 62 |
+
)
|
| 63 |
+
return response.choices[0].message.content
|
| 64 |
+
except Exception as e:
|
| 65 |
+
raise RuntimeError(f"vLLM call failed ({VLLM_BASE_URL}): {e}")
|
backend/main.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File, Form
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import StreamingResponse
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
import time
|
| 7 |
+
import json
|
| 8 |
+
import hashlib
|
| 9 |
+
import shutil
|
| 10 |
+
|
| 11 |
+
from backend.graph.graph import build_compilation_graph
|
| 12 |
+
from backend.sse import event_bus, emit
|
| 13 |
+
from backend.agent.brain_agent import handle_agent_query
|
| 14 |
+
from backend.db.supabase import get_client
|
| 15 |
+
from backend.llm import check_vllm_health
|
| 16 |
+
from backend.models.schemas import CompileRequest, AgentHandleRequest, AgentQueryRequest
|
| 17 |
+
|
| 18 |
+
app = FastAPI(title="Kernl API", version="2.0.0")
|
| 19 |
+
|
| 20 |
+
app.add_middleware(
|
| 21 |
+
CORSMiddleware,
|
| 22 |
+
allow_origins=["*"],
|
| 23 |
+
allow_credentials=True,
|
| 24 |
+
allow_methods=["*"],
|
| 25 |
+
allow_headers=["*"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 29 |
+
SOURCES_ROOT = os.path.join(BASE_DIR, "data", "sources")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ─────────────────────────────────────────────
|
| 33 |
+
# Health
|
| 34 |
+
# ─────────────────────────────────────────────
|
| 35 |
+
@app.get("/health")
|
| 36 |
+
async def health_check():
|
| 37 |
+
vllm = await check_vllm_health()
|
| 38 |
+
db = get_client()
|
| 39 |
+
return {
|
| 40 |
+
"status": "ok",
|
| 41 |
+
"vllm": vllm,
|
| 42 |
+
"database": "connected" if db else "not configured",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ─────────────────────────────────────────────
|
| 47 |
+
# Source file management
|
| 48 |
+
# ─────────────────────────────────────────────
|
| 49 |
+
def _company_sources_dir(company_id: str) -> str:
|
| 50 |
+
return os.path.join(SOURCES_ROOT, company_id)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.post("/sources/upload")
|
| 54 |
+
async def upload_source(company_id: str = Form(...), file: UploadFile = File(...)):
|
| 55 |
+
"""Upload a source file for a company."""
|
| 56 |
+
dest_dir = _company_sources_dir(company_id)
|
| 57 |
+
os.makedirs(dest_dir, exist_ok=True)
|
| 58 |
+
|
| 59 |
+
content = await file.read()
|
| 60 |
+
filepath = os.path.join(dest_dir, file.filename)
|
| 61 |
+
with open(filepath, "wb") as f:
|
| 62 |
+
f.write(content)
|
| 63 |
+
|
| 64 |
+
file_hash = hashlib.sha256(content).hexdigest()
|
| 65 |
+
|
| 66 |
+
# Record in DB
|
| 67 |
+
db = get_client()
|
| 68 |
+
if db:
|
| 69 |
+
try:
|
| 70 |
+
db.table("source_files").insert({
|
| 71 |
+
"company_id": company_id,
|
| 72 |
+
"filename": file.filename,
|
| 73 |
+
"sha256": file_hash,
|
| 74 |
+
"storage_path": f"data/sources/{company_id}/{file.filename}",
|
| 75 |
+
}).execute()
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"[upload] DB record error: {e}")
|
| 78 |
+
|
| 79 |
+
return {"filename": file.filename, "sha256": file_hash, "status": "uploaded"}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@app.get("/sources/{company_id}")
|
| 83 |
+
async def list_sources(company_id: str):
|
| 84 |
+
"""List all source files for a company."""
|
| 85 |
+
src_dir = _company_sources_dir(company_id)
|
| 86 |
+
if not os.path.isdir(src_dir):
|
| 87 |
+
return {"files": []}
|
| 88 |
+
files = []
|
| 89 |
+
for fn in sorted(os.listdir(src_dir)):
|
| 90 |
+
fp = os.path.join(src_dir, fn)
|
| 91 |
+
if os.path.isfile(fp):
|
| 92 |
+
with open(fp, "rb") as f:
|
| 93 |
+
content = f.read()
|
| 94 |
+
files.append({
|
| 95 |
+
"filename": fn,
|
| 96 |
+
"size_bytes": len(content),
|
| 97 |
+
"sha256": hashlib.sha256(content).hexdigest(),
|
| 98 |
+
})
|
| 99 |
+
return {"files": files, "company_id": company_id}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.delete("/sources/{company_id}/{filename}")
|
| 103 |
+
async def delete_source(company_id: str, filename: str):
|
| 104 |
+
"""Delete a source file."""
|
| 105 |
+
filepath = os.path.join(_company_sources_dir(company_id), filename)
|
| 106 |
+
if not os.path.isfile(filepath):
|
| 107 |
+
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
| 108 |
+
os.remove(filepath)
|
| 109 |
+
|
| 110 |
+
db = get_client()
|
| 111 |
+
if db:
|
| 112 |
+
try:
|
| 113 |
+
db.table("source_files").delete().eq(
|
| 114 |
+
"company_id", company_id
|
| 115 |
+
).eq("filename", filename).execute()
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"[delete] DB cleanup error: {e}")
|
| 118 |
+
|
| 119 |
+
return {"status": "deleted", "filename": filename}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ─────────────────────────────────────────────
|
| 123 |
+
# Compilation pipeline
|
| 124 |
+
# ─────────────────────────────────────────────
|
| 125 |
+
import asyncio
|
| 126 |
+
import traceback
|
| 127 |
+
import datetime
|
| 128 |
+
|
| 129 |
+
async def run_compilation_graph(job_id: str, company_id: str):
|
| 130 |
+
initial_state = {
|
| 131 |
+
"job_id": job_id,
|
| 132 |
+
"company_id": company_id,
|
| 133 |
+
"source_files": [],
|
| 134 |
+
"chunks": [],
|
| 135 |
+
"clusters": {},
|
| 136 |
+
"raw_skills": [],
|
| 137 |
+
"skills_file": {},
|
| 138 |
+
"brain_version": "",
|
| 139 |
+
"start_time": time.time(),
|
| 140 |
+
"errors": [],
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
graph = build_compilation_graph()
|
| 144 |
+
|
| 145 |
+
await emit(job_id, "pipeline_start", {"company_id": company_id})
|
| 146 |
+
try:
|
| 147 |
+
# Prevent indefinite hanging
|
| 148 |
+
await asyncio.wait_for(graph.ainvoke(initial_state), timeout=600.0)
|
| 149 |
+
except Exception as e:
|
| 150 |
+
err_msg = str(e)
|
| 151 |
+
if isinstance(e, asyncio.TimeoutError):
|
| 152 |
+
err_msg = "Pipeline execution timed out after 600 seconds."
|
| 153 |
+
|
| 154 |
+
trace = traceback.format_exc()
|
| 155 |
+
print(f"Graph execution failed for {job_id}:\n{trace}")
|
| 156 |
+
|
| 157 |
+
await emit(job_id, "pipeline_error", {"error": err_msg, "traceback": trace})
|
| 158 |
+
# Update compile run status
|
| 159 |
+
db = get_client()
|
| 160 |
+
if db:
|
| 161 |
+
try:
|
| 162 |
+
db.table("compile_runs").update({
|
| 163 |
+
"status": "error",
|
| 164 |
+
"completed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
| 165 |
+
"error_detail": err_msg,
|
| 166 |
+
}).eq("id", job_id).execute()
|
| 167 |
+
except Exception as db_e:
|
| 168 |
+
print(f"Failed to update compile_runs with error status: {db_e}")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@app.post("/compile")
|
| 172 |
+
@app.post("/compile/run")
|
| 173 |
+
async def compile_brain(req: CompileRequest, background_tasks: BackgroundTasks):
|
| 174 |
+
# Verify source directory exists
|
| 175 |
+
src_dir = _company_sources_dir(req.company_id)
|
| 176 |
+
if not os.path.isdir(src_dir) or not os.listdir(src_dir):
|
| 177 |
+
raise HTTPException(
|
| 178 |
+
status_code=400,
|
| 179 |
+
detail=f"No source files found at data/sources/{req.company_id}/. Upload files first.",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
job_id = str(uuid.uuid4())
|
| 183 |
+
db = get_client()
|
| 184 |
+
|
| 185 |
+
if db:
|
| 186 |
+
try:
|
| 187 |
+
db.table("compile_runs").insert({
|
| 188 |
+
"id": job_id,
|
| 189 |
+
"company_id": req.company_id,
|
| 190 |
+
"status": "running",
|
| 191 |
+
}).execute()
|
| 192 |
+
except Exception as e:
|
| 193 |
+
print(f"Error creating run: {e}")
|
| 194 |
+
|
| 195 |
+
background_tasks.add_task(run_compilation_graph, job_id, req.company_id)
|
| 196 |
+
return {"job_id": job_id, "status": "started"}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@app.get("/compile/{job_id}/stream")
|
| 200 |
+
async def compile_stream(job_id: str):
|
| 201 |
+
return StreamingResponse(
|
| 202 |
+
event_bus.event_generator(job_id),
|
| 203 |
+
media_type="text/event-stream",
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@app.get("/compile/{job_id}/status")
|
| 208 |
+
async def compile_status(job_id: str):
|
| 209 |
+
db = get_client()
|
| 210 |
+
if not db:
|
| 211 |
+
return {"status": "unknown", "error_detail": "No DB"}
|
| 212 |
+
res = db.table("compile_runs").select("*").eq("id", job_id).execute()
|
| 213 |
+
if not res.data:
|
| 214 |
+
return {"status": "not_found"}
|
| 215 |
+
return res.data[0]
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ─────────────────────────────────────────────
|
| 219 |
+
# Agent query
|
| 220 |
+
# ─────────────────────────────────────────────
|
| 221 |
+
@app.post("/agent/handle")
|
| 222 |
+
async def agent_handle_endpoint(req: AgentHandleRequest):
|
| 223 |
+
"""Legacy endpoint — kept for frontend compat."""
|
| 224 |
+
result = await handle_agent_query(req.company_id, req.scenario, req.context, req.with_brain)
|
| 225 |
+
return result
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@app.post("/agent/query")
|
| 229 |
+
async def agent_query_endpoint(req: AgentQueryRequest):
|
| 230 |
+
"""New canonical endpoint."""
|
| 231 |
+
result = await handle_agent_query(
|
| 232 |
+
req.company_id,
|
| 233 |
+
req.scenario_text,
|
| 234 |
+
req.json_context,
|
| 235 |
+
req.with_brain,
|
| 236 |
+
)
|
| 237 |
+
return result
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ─────────────────────────────────────────────
|
| 241 |
+
# Skills & brain versions
|
| 242 |
+
# ─────────────────────────────────────────────
|
| 243 |
+
@app.get("/skills")
|
| 244 |
+
async def get_skills_legacy(company_id: str):
|
| 245 |
+
"""Legacy endpoint: returns raw brain_json."""
|
| 246 |
+
db = get_client()
|
| 247 |
+
if not db:
|
| 248 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 249 |
+
res = db.table("skills_files").select("brain_json").eq(
|
| 250 |
+
"company_id", company_id
|
| 251 |
+
).order("compiled_at", desc=True).limit(1).execute()
|
| 252 |
+
if not res.data:
|
| 253 |
+
return {"skills": []}
|
| 254 |
+
return res.data[0]["brain_json"]
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@app.get("/skills/{company_id}")
|
| 258 |
+
async def get_skills(company_id: str):
|
| 259 |
+
"""Returns detailed skills with metadata."""
|
| 260 |
+
db = get_client()
|
| 261 |
+
if not db:
|
| 262 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 263 |
+
|
| 264 |
+
res = db.table("skills_files").select("*").eq(
|
| 265 |
+
"company_id", company_id
|
| 266 |
+
).eq("is_current", True).execute()
|
| 267 |
+
|
| 268 |
+
if not res.data:
|
| 269 |
+
return {"skills": [], "version": None, "compiled_at": None}
|
| 270 |
+
|
| 271 |
+
brain = res.data[0]
|
| 272 |
+
skills = brain["brain_json"].get("skills", [])
|
| 273 |
+
return {
|
| 274 |
+
"skills": skills,
|
| 275 |
+
"version": brain["version"],
|
| 276 |
+
"compiled_at": brain["compiled_at"],
|
| 277 |
+
"source_hashes": brain.get("source_hashes", {}),
|
| 278 |
+
"brain_id": brain["id"],
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@app.get("/brain/versions/{company_id}")
|
| 283 |
+
async def list_brain_versions(company_id: str):
|
| 284 |
+
"""Lists all brain versions for a company."""
|
| 285 |
+
db = get_client()
|
| 286 |
+
if not db:
|
| 287 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 288 |
+
|
| 289 |
+
res = db.table("skills_files").select(
|
| 290 |
+
"id, version, compiled_at, is_current, source_hashes"
|
| 291 |
+
).eq("company_id", company_id).order("compiled_at", desc=True).execute()
|
| 292 |
+
|
| 293 |
+
versions = []
|
| 294 |
+
for row in res.data:
|
| 295 |
+
brain_json = None
|
| 296 |
+
# Get skill count from the full row
|
| 297 |
+
full = db.table("skills_files").select("brain_json").eq("id", row["id"]).execute()
|
| 298 |
+
skill_count = 0
|
| 299 |
+
if full.data:
|
| 300 |
+
skill_count = len(full.data[0]["brain_json"].get("skills", []))
|
| 301 |
+
versions.append({
|
| 302 |
+
"id": row["id"],
|
| 303 |
+
"version": row["version"],
|
| 304 |
+
"compiled_at": row["compiled_at"],
|
| 305 |
+
"is_current": row["is_current"],
|
| 306 |
+
"source_count": len(row.get("source_hashes", {})),
|
| 307 |
+
"skill_count": skill_count,
|
| 308 |
+
})
|
| 309 |
+
|
| 310 |
+
return {"versions": versions, "company_id": company_id}
|
backend/models/schemas.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional, Dict, Any
|
| 3 |
+
|
| 4 |
+
class CompileRequest(BaseModel):
|
| 5 |
+
company_id: str
|
| 6 |
+
force_recompile: bool = False
|
| 7 |
+
|
| 8 |
+
class AgentHandleRequest(BaseModel):
|
| 9 |
+
"""Legacy schema — kept for frontend compatibility."""
|
| 10 |
+
company_id: str
|
| 11 |
+
scenario: str
|
| 12 |
+
context: Optional[Dict[str, Any]] = None
|
| 13 |
+
with_brain: bool = True
|
| 14 |
+
|
| 15 |
+
class AgentQueryRequest(BaseModel):
|
| 16 |
+
"""New canonical schema for agent queries."""
|
| 17 |
+
company_id: str
|
| 18 |
+
scenario_text: str
|
| 19 |
+
json_context: Optional[Dict[str, Any]] = None
|
| 20 |
+
with_brain: bool = True
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
openai
|
| 4 |
+
langgraph>=0.4
|
| 5 |
+
sentence-transformers
|
| 6 |
+
numpy
|
| 7 |
+
supabase
|
| 8 |
+
python-dotenv
|
| 9 |
+
python-multipart
|
| 10 |
+
pydantic
|
backend/sse.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
from typing import Dict, AsyncGenerator
|
| 4 |
+
|
| 5 |
+
class CompilationEventBus:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
self.queues: Dict[str, asyncio.Queue] = {}
|
| 8 |
+
|
| 9 |
+
def get_queue(self, job_id: str) -> asyncio.Queue:
|
| 10 |
+
if job_id not in self.queues:
|
| 11 |
+
self.queues[job_id] = asyncio.Queue()
|
| 12 |
+
return self.queues[job_id]
|
| 13 |
+
|
| 14 |
+
async def emit_event(self, job_id: str, event_type: str, data: dict):
|
| 15 |
+
queue = self.get_queue(job_id)
|
| 16 |
+
await queue.put({"type": event_type, "data": data})
|
| 17 |
+
|
| 18 |
+
async def event_generator(self, job_id: str) -> AsyncGenerator[str, None]:
|
| 19 |
+
"""Yields SSE-formatted strings. Uses unnamed events so the
|
| 20 |
+
frontend's EventSource.onmessage handler fires correctly.
|
| 21 |
+
Payload: data: {"event": "<type>", "data": {<payload>}}\n\n
|
| 22 |
+
"""
|
| 23 |
+
queue = self.get_queue(job_id)
|
| 24 |
+
try:
|
| 25 |
+
while True:
|
| 26 |
+
event = await asyncio.wait_for(queue.get(), timeout=300)
|
| 27 |
+
payload = json.dumps({"event": event["type"], "data": event["data"]})
|
| 28 |
+
yield f"data: {payload}\n\n"
|
| 29 |
+
if event["type"] in ["pipeline_complete", "pipeline_error"]:
|
| 30 |
+
break
|
| 31 |
+
except asyncio.TimeoutError:
|
| 32 |
+
yield f'data: {json.dumps({"event": "timeout", "data": {}})}\n\n'
|
| 33 |
+
finally:
|
| 34 |
+
if job_id in self.queues:
|
| 35 |
+
del self.queues[job_id]
|
| 36 |
+
|
| 37 |
+
event_bus = CompilationEventBus()
|
| 38 |
+
|
| 39 |
+
async def emit(job_id: str, event_type: str, data: dict):
|
| 40 |
+
await event_bus.emit_event(job_id, event_type, data)
|
backend/test_compile.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import uuid
|
| 5 |
+
import sys
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
# Set backend in path
|
| 9 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 10 |
+
|
| 11 |
+
from backend.graph.graph import build_compilation_graph
|
| 12 |
+
|
| 13 |
+
async def run_compilation_test():
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
# Check vLLM
|
| 17 |
+
vllm_url = os.getenv("VLLM_BASE_URL")
|
| 18 |
+
if not vllm_url:
|
| 19 |
+
print("VLLM_BASE_URL not set in .env. LLM calls will fail.")
|
| 20 |
+
else:
|
| 21 |
+
print(f"Using VLLM_BASE_URL: {vllm_url}")
|
| 22 |
+
|
| 23 |
+
company_id = "rivanly-inc"
|
| 24 |
+
job_id = str(uuid.uuid4())
|
| 25 |
+
|
| 26 |
+
# Read files
|
| 27 |
+
source_files = []
|
| 28 |
+
sources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "sources")
|
| 29 |
+
if os.path.exists(sources_dir):
|
| 30 |
+
import hashlib
|
| 31 |
+
for filename in os.listdir(sources_dir):
|
| 32 |
+
filepath = os.path.join(sources_dir, filename)
|
| 33 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 34 |
+
content = f.read()
|
| 35 |
+
|
| 36 |
+
ftype = "unknown"
|
| 37 |
+
if filename.endswith(".json"):
|
| 38 |
+
if "slack" in filename: ftype = "slack_json"
|
| 39 |
+
elif "tickets" in filename: ftype = "tickets_json"
|
| 40 |
+
elif filename.endswith(".md"):
|
| 41 |
+
ftype = "notion_md"
|
| 42 |
+
|
| 43 |
+
source_files.append({
|
| 44 |
+
"filename": filename,
|
| 45 |
+
"content": content,
|
| 46 |
+
"type": ftype,
|
| 47 |
+
"sha256": hashlib.sha256(content.encode('utf-8')).hexdigest()
|
| 48 |
+
})
|
| 49 |
+
else:
|
| 50 |
+
print(f"No sources dir found at {sources_dir}")
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
print(f"Found {len(source_files)} source files. Starting graph...")
|
| 54 |
+
|
| 55 |
+
initial_state = {
|
| 56 |
+
"job_id": job_id,
|
| 57 |
+
"company_id": company_id,
|
| 58 |
+
"source_files": source_files,
|
| 59 |
+
"structured_sops": [],
|
| 60 |
+
"normalized_events": [],
|
| 61 |
+
"resolved_cases": [],
|
| 62 |
+
"extracted_decisions": [],
|
| 63 |
+
"extracted_workflows": [],
|
| 64 |
+
"extracted_exceptions": [],
|
| 65 |
+
"detected_contradictions": [],
|
| 66 |
+
"skills_file": {}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
graph = build_compilation_graph()
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
final_state = await graph.ainvoke(initial_state)
|
| 73 |
+
print("\n=== COMPILATION COMPLETE ===")
|
| 74 |
+
print(f"Extracted Decisions: {len(final_state.get('extracted_decisions', []))}")
|
| 75 |
+
print(f"Detected Contradictions: {len(final_state.get('detected_contradictions', []))}")
|
| 76 |
+
for c in final_state.get('detected_contradictions', []):
|
| 77 |
+
print(f" - Contradiction: {c}")
|
| 78 |
+
|
| 79 |
+
skills_file = final_state.get('skills_file', {})
|
| 80 |
+
skills = skills_file.get('skills', [])
|
| 81 |
+
print(f"Generated Skills: {len(skills)}")
|
| 82 |
+
for s in skills:
|
| 83 |
+
print(f" - {s.get('id')} ({s.get('confidence')} conf)")
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"Graph execution failed: {e}")
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
asyncio.run(run_compilation_test())
|
brand_alchemy_company_brain.html
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
<style>
|
| 3 |
+
.ba-root { font-family: var(--font-sans); padding: 1.5rem 0; }
|
| 4 |
+
.section-label { font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-tertiary); margin-bottom: 0.75rem; }
|
| 5 |
+
.pov-block { border-left: 2px solid var(--color-border-secondary); padding: 0.75rem 1rem; margin-bottom: 1.5rem; }
|
| 6 |
+
.pov-block p { margin: 0; font-size: 15px; color: var(--color-text-primary); line-height: 1.6; }
|
| 7 |
+
.pov-block .pov-sub { font-size: 13px; color: var(--color-text-secondary); margin-top: 0.4rem; }
|
| 8 |
+
.meta-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 2rem; }
|
| 9 |
+
.meta-card { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 0.75rem 1rem; }
|
| 10 |
+
.meta-card .mk { font-size: 11px; color: var(--color-text-tertiary); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.06em; }
|
| 11 |
+
.meta-card .mv { font-size: 13px; font-weight: 500; color: var(--color-text-primary); }
|
| 12 |
+
.name-card { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 1.25rem; margin-bottom: 0.75rem; }
|
| 13 |
+
.name-card.winner { border: 1.5px solid #1D9E75; }
|
| 14 |
+
.name-header { display: flex; align-items: center; gap: 12px; margin-bottom: 0.75rem; }
|
| 15 |
+
.name-title { font-size: 22px; font-weight: 500; color: var(--color-text-primary); letter-spacing: -0.02em; }
|
| 16 |
+
.name-score { display: flex; gap: 6px; margin-left: auto; align-items: center; }
|
| 17 |
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-border-tertiary); }
|
| 18 |
+
.dot.filled { background: #1D9E75; }
|
| 19 |
+
.winner-badge { font-size: 11px; font-weight: 500; background: #E1F5EE; color: #0F6E56; padding: 3px 10px; border-radius: 20px; }
|
| 20 |
+
.name-tagline { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 0.75rem; font-style: italic; }
|
| 21 |
+
.phono-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
| 22 |
+
.phono-pill { font-size: 11px; background: var(--color-background-secondary); border: 0.5px solid var(--color-border-tertiary); color: var(--color-text-secondary); padding: 3px 10px; border-radius: 20px; }
|
| 23 |
+
.phono-pill.p { background: #EEEDFE; color: #3C3489; border-color: #AFA9EC; }
|
| 24 |
+
.phono-pill.l { background: #E1F5EE; color: #0F6E56; border-color: #5DCAA5; }
|
| 25 |
+
.phono-pill.f { background: #FAEEDA; color: #633806; border-color: #EF9F27; }
|
| 26 |
+
.name-reasoning { font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 0.75rem; }
|
| 27 |
+
.domain-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 28 |
+
.domain-tag { font-size: 12px; font-weight: 500; padding: 3px 10px; border-radius: 20px; display: flex; align-items: center; gap: 5px; }
|
| 29 |
+
.domain-tag.available { background: #E1F5EE; color: #085041; }
|
| 30 |
+
.domain-tag.taken { background: #FCEBEB; color: #791F1F; }
|
| 31 |
+
.diamond-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 2rem; }
|
| 32 |
+
.diamond-item { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 0.6rem; text-align: center; }
|
| 33 |
+
.diamond-label { font-size: 11px; color: var(--color-text-tertiary); margin-bottom: 4px; }
|
| 34 |
+
.diamond-bar-bg { height: 4px; background: var(--color-border-tertiary); border-radius: 2px; overflow: hidden; }
|
| 35 |
+
.diamond-bar-fill { height: 4px; border-radius: 2px; background: #1D9E75; }
|
| 36 |
+
.diamond-score { font-size: 12px; font-weight: 500; color: var(--color-text-primary); margin-top: 4px; }
|
| 37 |
+
.divider { border: none; border-top: 0.5px solid var(--color-border-tertiary); margin: 1.5rem 0; }
|
| 38 |
+
.rec-block { background: #E1F5EE; border-radius: var(--border-radius-lg); padding: 1.25rem; margin-top: 1rem; }
|
| 39 |
+
.rec-title { font-size: 14px; font-weight: 500; color: #085041; margin-bottom: 0.5rem; }
|
| 40 |
+
.rec-text { font-size: 13px; color: #0F6E56; line-height: 1.6; }
|
| 41 |
+
.positioning-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 1.5rem; }
|
| 42 |
+
.positioning-table td { padding: 8px 12px; border-bottom: 0.5px solid var(--color-border-tertiary); color: var(--color-text-secondary); vertical-align: top; }
|
| 43 |
+
.positioning-table td:first-child { font-weight: 500; color: var(--color-text-primary); width: 40%; }
|
| 44 |
+
</style>
|
| 45 |
+
|
| 46 |
+
<div class="ba-root">
|
| 47 |
+
<h2 style="sr-only">Brand Alchemy Report — Company Brain</h2>
|
| 48 |
+
|
| 49 |
+
<div class="section-label">Product DNA — what was learned</div>
|
| 50 |
+
<div class="pov-block">
|
| 51 |
+
<p>A compiler layer that extracts the operational judgment scattered across Slack, SOPs, tickets and people's heads — and produces a versioned, evidence-linked, executable skills file any AI agent can consume.</p>
|
| 52 |
+
<p class="pov-sub">Not RAG. Not a chatbot. Not search. A compiler of how a company actually decides things — living, stale-aware, and infrastructure-grade.</p>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="meta-grid">
|
| 56 |
+
<div class="meta-card">
|
| 57 |
+
<div class="mk">Category</div>
|
| 58 |
+
<div class="mv">AI Infrastructure</div>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="meta-card">
|
| 61 |
+
<div class="mk">Audience</div>
|
| 62 |
+
<div class="mv">B2B SaaS Ops + AI Builders</div>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="meta-card">
|
| 65 |
+
<div class="mk">Brand vibe</div>
|
| 66 |
+
<div class="mv">Authoritative + Precise</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="meta-card">
|
| 69 |
+
<div class="mk">Core metaphor</div>
|
| 70 |
+
<div class="mv">Compiler, not assistant</div>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="meta-card">
|
| 73 |
+
<div class="mk">Alternative today</div>
|
| 74 |
+
<div class="mv">Long system prompts, Notion docs</div>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="meta-card">
|
| 77 |
+
<div class="mk">The moat</div>
|
| 78 |
+
<div class="mv">Stale detection + evidence trail</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<hr class="divider">
|
| 83 |
+
|
| 84 |
+
<div class="section-label">Category point of view (POV)</div>
|
| 85 |
+
<div class="pov-block" style="margin-bottom: 2rem;">
|
| 86 |
+
<p>The old category — "knowledge management" — is about humans finding information. The new category is <strong style="color:var(--color-text-primary)">operational memory infrastructure</strong>: the persistent, executable layer that lets AI agents inherit a company's judgment. The race isn't between AI models. It's between companies that give their agents operational memory and those that don't.</p>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<hr class="divider">
|
| 90 |
+
|
| 91 |
+
<div class="section-label">Phonosemantics key — sound symbolism used</div>
|
| 92 |
+
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom: 1.5rem;">
|
| 93 |
+
<span class="phono-pill p">P — plosive → authority, precision</span>
|
| 94 |
+
<span class="phono-pill l">L — liquid → intelligence, flow</span>
|
| 95 |
+
<span class="phono-pill f">F — fricative → speed, edge</span>
|
| 96 |
+
<span class="phono-pill" style="background:#FAECE7;color:#712B13;border-color:#F0997B;">N — nasal → warmth, connection</span>
|
| 97 |
+
<span class="phono-pill" style="background:#E6F1FB;color:#0C447C;border-color:#85B7EB;">K — hard plosive → technical force</span>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="section-label">5 name candidates — linguistic breakdown + domain verification</div>
|
| 101 |
+
|
| 102 |
+
<!-- KERNL -->
|
| 103 |
+
<div class="name-card winner">
|
| 104 |
+
<div class="name-header">
|
| 105 |
+
<div class="name-title">Kernl</div>
|
| 106 |
+
<span class="winner-badge">★ top pick</span>
|
| 107 |
+
<div class="name-score">
|
| 108 |
+
<div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
<div class="name-tagline">"The operational kernel your AI agents run on."</div>
|
| 112 |
+
<div class="phono-row">
|
| 113 |
+
<span class="phono-pill p">K plosive — technical force</span>
|
| 114 |
+
<span class="phono-pill l">R liquid — intelligence</span>
|
| 115 |
+
<span class="phono-pill" style="background:#FAECE7;color:#712B13;border-color:#F0997B;">N nasal — familiar</span>
|
| 116 |
+
<span class="phono-pill" style="background:#E6F1FB;color:#0C447C;border-color:#85B7EB;">dropped 'e' — modern tech register</span>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="name-reasoning">A kernel is the essential core of an operating system — the layer that mediates between hardware and everything above it. Kernl <em>is</em> that layer for AI agents: between raw company data and reliable automation. The dropped final 'e' (à la Tumblr, Flickr) signals a distinctly technical register. Infrastructure-grade naming — belongs alongside Redis, Kafka, Postgres.</div>
|
| 119 |
+
<div class="domain-row">
|
| 120 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> kernl.com</span>
|
| 121 |
+
<span class="domain-tag taken"><i class="ti ti-x" aria-hidden="true"></i> kernl.ai</span>
|
| 122 |
+
<span class="domain-tag taken"><i class="ti ti-x" aria-hidden="true"></i> kernl.io</span>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- MELD -->
|
| 127 |
+
<div class="name-card">
|
| 128 |
+
<div class="name-header">
|
| 129 |
+
<div class="name-title">Meld</div>
|
| 130 |
+
<div class="name-score">
|
| 131 |
+
<div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot"></div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
<div class="name-tagline">"Melds scattered knowledge into a single executable mind."</div>
|
| 135 |
+
<div class="phono-row">
|
| 136 |
+
<span class="phono-pill" style="background:#FAECE7;color:#712B13;border-color:#F0997B;">M nasal — warmth, familiarity</span>
|
| 137 |
+
<span class="phono-pill l">L liquid — intelligence</span>
|
| 138 |
+
<span class="phono-pill p">D plosive — decisive, final</span>
|
| 139 |
+
<span class="phono-pill" style="background:#E6F1FB;color:#0C447C;border-color:#85B7EB;">1 syllable — maximum compression</span>
|
| 140 |
+
</div>
|
| 141 |
+
<div class="name-reasoning">To meld is to blend disparate elements into a unified whole. In card games, it means to lay down your hidden hand — revealing what was implicit. Both meanings map perfectly: Meld takes scattered, implicit operational knowledge and merges it into explicit, executable form. One syllable, pure infrastructure energy, zero ambiguity.</div>
|
| 142 |
+
<div class="domain-row">
|
| 143 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> meld.com</span>
|
| 144 |
+
<span class="domain-tag taken"><i class="ti ti-x" aria-hidden="true"></i> meld.ai</span>
|
| 145 |
+
<span class="domain-tag taken"><i class="ti ti-x" aria-hidden="true"></i> meld.io</span>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<!-- OPSLORE -->
|
| 150 |
+
<div class="name-card">
|
| 151 |
+
<div class="name-header">
|
| 152 |
+
<div class="name-title">Opslore</div>
|
| 153 |
+
<div class="name-score">
|
| 154 |
+
<div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot"></div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="name-tagline">"The living operational lore of your company, made executable."</div>
|
| 158 |
+
<div class="phono-row">
|
| 159 |
+
<span class="phono-pill p">P plosive — operational precision</span>
|
| 160 |
+
<span class="phono-pill f">S fricative — speed</span>
|
| 161 |
+
<span class="phono-pill l">L+R liquids — intelligence, depth</span>
|
| 162 |
+
</div>
|
| 163 |
+
<div class="name-reasoning">Lore is the body of accumulated, traditional knowledge belonging to a group — the unwritten rules, the cultural wisdom. "Opslore" is the operational lore of a company: how refunds get handled, why escalation chains exist, what the pricing exception rule actually is. The word has warmth and depth while remaining precise. Best domain position of any candidate — .com, .ai, and .io all clear.</div>
|
| 164 |
+
<div class="domain-row">
|
| 165 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> opslore.com</span>
|
| 166 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> opslore.ai</span>
|
| 167 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> opslore.io</span>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<!-- OPSCODEX -->
|
| 172 |
+
<div class="name-card">
|
| 173 |
+
<div class="name-header">
|
| 174 |
+
<div class="name-title">Opscodex</div>
|
| 175 |
+
<div class="name-score">
|
| 176 |
+
<div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot"></div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="name-tagline">"The compiled operational codex your agents execute from."</div>
|
| 180 |
+
<div class="phono-row">
|
| 181 |
+
<span class="phono-pill p">K+P plosives — double authority</span>
|
| 182 |
+
<span class="phono-pill" style="background:#EEEDFE;color:#3C3489;border-color:#AFA9EC;">X terminal — distinct, rare</span>
|
| 183 |
+
<span class="phono-pill" style="background:#E6F1FB;color:#0C447C;border-color:#85B7EB;">Codex etymology — compiled law</span>
|
| 184 |
+
</div>
|
| 185 |
+
<div class="name-reasoning">A codex was the ancient form of the book — sheets compiled and bound, replacing scrolls. Historically, codices preserved legal codes, canonical texts, laws. Opscodex is the compiled operational code of a company: the canonical, authoritative record of how things are decided. The terminal X adds sonic distinctiveness and technical sharpness. Carries scholarly gravitas — infrastructure, not a feature.</div>
|
| 186 |
+
<div class="domain-row">
|
| 187 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> opscodex.com</span>
|
| 188 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> opscodex.ai</span>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
<!-- LOREKERN -->
|
| 193 |
+
<div class="name-card">
|
| 194 |
+
<div class="name-header">
|
| 195 |
+
<div class="name-title">Lorekern</div>
|
| 196 |
+
<div class="name-score">
|
| 197 |
+
<div class="dot filled"></div><div class="dot filled"></div><div class="dot filled"></div><div class="dot"></div><div class="dot"></div>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
<div class="name-tagline">"The kernel of operational lore, distilled and executable."</div>
|
| 201 |
+
<div class="phono-row">
|
| 202 |
+
<span class="phono-pill l">L+R liquids — intelligence, flow</span>
|
| 203 |
+
<span class="phono-pill p">K plosive — technical core</span>
|
| 204 |
+
<span class="phono-pill" style="background:#FAECE7;color:#712B13;border-color:#F0997B;">N nasal — grounding</span>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="name-reasoning">Morpheme blend of Lore (accumulated operational wisdom) and Kern (kernel/core). Reads as two concepts merged — like the product itself: taking the living lore of an organization and distilling it into an executable core. More descriptive and compound than the other candidates; works well if a human-warmth brand angle is preferred over pure infrastructure framing.</div>
|
| 207 |
+
<div class="domain-row">
|
| 208 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> lorekern.com</span>
|
| 209 |
+
<span class="domain-tag available"><i class="ti ti-check" aria-hidden="true"></i> lorekern.ai</span>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<hr class="divider">
|
| 214 |
+
|
| 215 |
+
<div class="section-label">Diamond test — top pick: Kernl</div>
|
| 216 |
+
<div class="diamond-grid">
|
| 217 |
+
<div class="diamond-item">
|
| 218 |
+
<div class="diamond-label">Distinctiveness</div>
|
| 219 |
+
<div class="diamond-bar-bg"><div class="diamond-bar-fill" style="width:96%"></div></div>
|
| 220 |
+
<div class="diamond-score">96</div>
|
| 221 |
+
</div>
|
| 222 |
+
<div class="diamond-item">
|
| 223 |
+
<div class="diamond-label">Processing fluency</div>
|
| 224 |
+
<div class="diamond-bar-bg"><div class="diamond-bar-fill" style="width:95%"></div></div>
|
| 225 |
+
<div class="diamond-score">95</div>
|
| 226 |
+
</div>
|
| 227 |
+
<div class="diamond-item">
|
| 228 |
+
<div class="diamond-label">Relevance</div>
|
| 229 |
+
<div class="diamond-bar-bg"><div class="diamond-bar-fill" style="width:97%"></div></div>
|
| 230 |
+
<div class="diamond-score">97</div>
|
| 231 |
+
</div>
|
| 232 |
+
<div class="diamond-item">
|
| 233 |
+
<div class="diamond-label">Energy</div>
|
| 234 |
+
<div class="diamond-bar-bg"><div class="diamond-bar-fill" style="width:88%"></div></div>
|
| 235 |
+
<div class="diamond-score">88</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div class="rec-block">
|
| 240 |
+
<div class="rec-title">Final recommendation: Kernl</div>
|
| 241 |
+
<div class="rec-text">Register <strong>kernl.com</strong> immediately. The kernel metaphor is structurally perfect — it is the deepest, most precise analogy in the OS/infra vocabulary for what this product does. The hard K plosive delivers maximum technical authority. The dropped 'e' places it firmly in infrastructure naming tradition. It scales: "your company's Kernl", "deploy Kernl", "the Kernl API". It competes with Redis and Kafka on naming gravity, which is exactly the positioning the PRD demands.</div>
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<hr class="divider">
|
| 245 |
+
|
| 246 |
+
<div class="section-label">Visual system direction</div>
|
| 247 |
+
<table class="positioning-table">
|
| 248 |
+
<tr><td>Mark style</td><td>Wordmark only. No icon. Infrastructure products don't need icons — they are the icon. Monospace or geometric sans. Think Stripe, Linear, Vercel.</td></tr>
|
| 249 |
+
<tr><td>Color</td><td>Single accent on near-black ground. Deep teal (#0F6E56) or electric indigo — evokes precision and living systems without the generic "AI blue" trap.</td></tr>
|
| 250 |
+
<tr><td>Voice</td><td>Declarative. Short sentences. Never explain — demonstrate. "12 skills. 58 seconds. Evidence-linked." — not "our platform leverages AI to..."</td></tr>
|
| 251 |
+
<tr><td>Tagline direction</td><td>"Operational memory for AI agents." — or — "Your AI knows how your company works."</td></tr>
|
| 252 |
+
<tr><td>Pitch one-liner</td><td>"Kernl compiles how your company decides into an executable skills file. Any agent. Any task. Correct every time."</td></tr>
|
| 253 |
+
</table>
|
| 254 |
+
</div>
|
company_brain_PRD_v4.md
ADDED
|
@@ -0,0 +1,1061 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Company Brain — Product Requirements Document
|
| 2 |
+
**Version:** 4.0 — Final (Pre-Build, All Issues Resolved)
|
| 3 |
+
**Date:** May 4, 2026
|
| 4 |
+
**Authors:** Abhijith Pingali, Harshit Anand
|
| 5 |
+
**Status:** Final — Build starts post-kickoff
|
| 6 |
+
|
| 7 |
+
> **v4 changes over v3:**
|
| 8 |
+
> 1. Ground truth table completed — all 12 Rivanly scenarios with expected action + skill
|
| 9 |
+
> 2. `with_brain: false` behaviour fully documented in Section 9
|
| 10 |
+
> 3. Section 10 user flow added — screen-to-screen navigation with decision points
|
| 11 |
+
> 4. Competitive landscape updated with 8 real companies identified in LinkedIn thread
|
| 12 |
+
> 5. Risk table updated: "knowledge never captured" risk added from Paul Breuler's comment
|
| 13 |
+
> 6. Section 2.5 added: "The Stale Knowledge Problem" — validates drift detection as core feature
|
| 14 |
+
> 7. Section 15 updated: execution boundary insight from Horizon Labs added to v2 roadmap
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 1. Executive Summary
|
| 19 |
+
|
| 20 |
+
**Problem:** AI agents deployed by B2B companies behave like a new hire on day one — they lack the operational judgment embedded in how the company actually decides things. This knowledge lives in Slack threads, SOPs, support tickets, and people's heads, invisible to any model.
|
| 21 |
+
|
| 22 |
+
**Solution:** Company Brain is a compilation layer that extracts this operational judgment and produces a versioned, evidence-linked, executable skills file any AI agent can consume to act like the company's best employee.
|
| 23 |
+
|
| 24 |
+
**Success Criteria (Hackathon v0):**
|
| 25 |
+
|
| 26 |
+
| KPI | Target |
|
| 27 |
+
|---|---|
|
| 28 |
+
| Full compilation pipeline: sources → 12 skills | Completes without error, every run |
|
| 29 |
+
| Skills with confidence ≥ 0.7 | ≥ 10 of 12 |
|
| 30 |
+
| Brain agent: correct action on all Rivanly scenarios | 12 / 12 correct |
|
| 31 |
+
| Compilation time on AMD MI300X | < 90s (target 60s) |
|
| 32 |
+
| Brain agent response latency | < 8s per query |
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 2. Problem Statement & Solution
|
| 37 |
+
|
| 38 |
+
### 2.1 The Problem
|
| 39 |
+
|
| 40 |
+
Every company trying to deploy AI automation hits the same wall. The models are good enough. The infrastructure is available. But the AI behaves like a new hire on day one — it doesn't know how the company actually operates.
|
| 41 |
+
|
| 42 |
+
Refund policies live in Priya's head. Pricing exceptions get decided in Slack threads nobody archived. Escalation chains exist because three incidents taught the team the hard way. This operational knowledge — how the company actually decides things — is invisible to AI agents.
|
| 43 |
+
|
| 44 |
+
Existing solutions miss this entirely. RAG retrieves document chunks. Chatbots answer questions. Neither gives an AI agent the operational judgment to do real work correctly and consistently.
|
| 45 |
+
|
| 46 |
+
### 2.2 The Solution
|
| 47 |
+
|
| 48 |
+
Company Brain is the missing compilation layer. It extracts the operational judgment embedded in how a company behaves — not what it documents, but how it actually decides — and compiles it into an executable, versioned, living skills file that any AI agent can use.
|
| 49 |
+
|
| 50 |
+
**Agents are compilers, not assistants.** Company Brain's extraction agents do not summarize or search. They convert messy human behavior into structured, executable logic. The downstream brain agent that does real work is a consumer of that compiled output — it never reasons from scratch.
|
| 51 |
+
|
| 52 |
+
### 2.3 One-Line Pitch
|
| 53 |
+
|
| 54 |
+
> "We turn how your company actually operates into an executable Company Brain. Any agent can use it to do real work without guessing."
|
| 55 |
+
|
| 56 |
+
### 2.4 Product Positioning
|
| 57 |
+
|
| 58 |
+
Company Brain is **infrastructure, not a feature.**
|
| 59 |
+
|
| 60 |
+
| What it is NOT | What it IS |
|
| 61 |
+
|---|---|
|
| 62 |
+
| RAG over documents | Compiler of operational judgment |
|
| 63 |
+
| Chatbot over your data | Executable skills file for AI agents |
|
| 64 |
+
| A search engine | A living map of how your company works |
|
| 65 |
+
| One-time snapshot | Versioned, updatable, drift-aware |
|
| 66 |
+
|
| 67 |
+
### 2.5 The Stale Knowledge Problem (Why "Living" Matters)
|
| 68 |
+
|
| 69 |
+
*Validated by multiple practitioners in the YC RFS LinkedIn thread.*
|
| 70 |
+
|
| 71 |
+
The hardest part of any knowledge system is not building it — it is keeping it alive. Most companies will document their workflows once, ship the AI agent, and within six weeks the map diverges from reality. A new pricing exception gets approved in a Slack DM. An escalation chain changes when someone leaves. The AI keeps following the old rules.
|
| 72 |
+
|
| 73 |
+
Company Brain's stale detection — SHA-256 hashing of source files, `stale: true` badges on affected skills, and recompile triggers — directly solves this. The skills file is not a document. It is a living artifact that stays current with how the company actually evolves.
|
| 74 |
+
|
| 75 |
+
This is not a minor feature. It is the moat.
|
| 76 |
+
|
| 77 |
+
### 2.6 What Company Brain Does NOT Solve (v0)
|
| 78 |
+
|
| 79 |
+
*Acknowledged risk from Paul Breuler (BaseState founder), LinkedIn thread:*
|
| 80 |
+
|
| 81 |
+
> "The decisions that matter happen in context, on the ground, and were never captured in a ticket or Slack thread."
|
| 82 |
+
|
| 83 |
+
Company Brain compiles knowledge that was captured somewhere — Slack, SOPs, tickets, call transcripts. Knowledge that exists only in someone's head and was never written down or discussed in any recorded channel cannot be extracted. This is a known limitation. The pitch should never claim to capture all company knowledge — only the knowledge that was communicated in any digital form.
|
| 84 |
+
|
| 85 |
+
For v1, voice call transcription addresses a portion of this gap. For v2, an active knowledge capture interface (where employees record decisions as they happen) closes it further.
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## 3. Target Customer & User Personas
|
| 90 |
+
|
| 91 |
+
### 3.1 Primary Wedge — v1
|
| 92 |
+
|
| 93 |
+
**B2B SaaS companies, 10–50 employees, actively deploying AI automation for the first time.**
|
| 94 |
+
|
| 95 |
+
These companies have:
|
| 96 |
+
- Enough operational complexity that AI agents fail without context
|
| 97 |
+
- Enough technical sophistication to understand why RAG isn't solving their problem
|
| 98 |
+
- Enough urgency to pay for a solution (they are actively trying and failing to deploy AI)
|
| 99 |
+
- Not enough resources to build this infrastructure themselves
|
| 100 |
+
|
| 101 |
+
### 3.2 User Personas
|
| 102 |
+
|
| 103 |
+
| Persona | Role | Primary Goal | Pain Point |
|
| 104 |
+
|---|---|---|---|
|
| 105 |
+
| **Ops Owner** | Head of Operations / Founder | Get AI agents to handle work consistently | Agents hallucinate edge cases; policies go stale |
|
| 106 |
+
| **AI Builder** | Developer / Automation Lead | Consume company knowledge in agent prompts | No structured source of truth to inject into prompts |
|
| 107 |
+
| **Agent Consumer** | Support agent, AM, anyone using the AI | Get correct, policy-backed responses instantly | Generic AI responses that don't match company policy |
|
| 108 |
+
| **Demo Viewer / Judge** | Hackathon judge, investor, prospect | Understand what the product does in 5 minutes | Can't distinguish this from "just another RAG tool" |
|
| 109 |
+
|
| 110 |
+
### 3.3 Fictional Reference Customer — Rivanly Inc.
|
| 111 |
+
|
| 112 |
+
Rivanly is a 15-person B2B SaaS company used throughout this document and the demo. 6 departments, 12 operational skills. Enough complexity to make the product real, small enough to demo in 5 minutes.
|
| 113 |
+
|
| 114 |
+
### 3.4 Expanded Customer Universe — v2+
|
| 115 |
+
|
| 116 |
+
- E-commerce operators (refund, shipping, returns automation)
|
| 117 |
+
- Agencies (client approval, scope change, billing exception workflows)
|
| 118 |
+
- Healthcare admin (referral routing, prior authorization, scheduling exceptions)
|
| 119 |
+
- Legal operations (intake, escalation, matter routing)
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 4. Jobs To Be Done
|
| 124 |
+
|
| 125 |
+
| Job | Current Solution | Problem |
|
| 126 |
+
|---|---|---|
|
| 127 |
+
| "I want an AI agent to handle customer refunds correctly" | Write a long system prompt with refund rules | Rules go stale, edge cases missed, no evidence trail |
|
| 128 |
+
| "I need to onboard a new AI tool to how we operate" | Document everything manually in Notion | Takes weeks, immediately outdated, agent still hallucinates |
|
| 129 |
+
| "I want to know if my AI agent is following company policy" | Read agent logs manually | No structured audit trail linking actions to rules |
|
| 130 |
+
| "We updated our pricing policy — the AI needs to know" | Edit the system prompt manually | No systematic way to detect or propagate policy changes |
|
| 131 |
+
| "Why did the agent make that decision?" | Cannot answer | No evidence chain from agent action back to source |
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## 5. User Stories
|
| 136 |
+
|
| 137 |
+
### Source Ingestion & File Handling
|
| 138 |
+
|
| 139 |
+
1. As an Ops Owner, I want to upload `.md` Notion SOPs so that my written policies are ingested without manual reformatting.
|
| 140 |
+
2. As an Ops Owner, I want to upload Slack JSON exports so that informal decision patterns from real conversations are captured.
|
| 141 |
+
3. As an Ops Owner, I want to upload Zendesk ticket JSON exports so that resolved case reasoning is extracted as evidence.
|
| 142 |
+
4. As an Ops Owner, I want the system to detect unchanged files by SHA-256 hash so that re-uploading a file doesn't trigger unnecessary re-extraction.
|
| 143 |
+
5. As an Ops Owner, I want a clear parse error message when a file is malformed so that I know exactly which file to fix.
|
| 144 |
+
6. As an Ops Owner, I want unsupported file types to be rejected with a helpful error so that I don't wait for a compilation that will fail.
|
| 145 |
+
7. As an Ops Owner, I want source files stored in Supabase so that I don't need to re-upload them on every compile.
|
| 146 |
+
|
| 147 |
+
### Compilation Pipeline
|
| 148 |
+
|
| 149 |
+
8. As an Ops Owner, I want 4 extraction agents to run in parallel on AMD MI300X so that compilation completes under 90 seconds instead of 8+ minutes.
|
| 150 |
+
9. As an Ops Owner, I want IF-THEN-EXCEPT decision rules extracted from Slack threads so that informal decisions become structured, executable policies.
|
| 151 |
+
10. As an Ops Owner, I want sequential process steps extracted from SOPs and runbooks so that workflow sequences are captured correctly.
|
| 152 |
+
11. As an Ops Owner, I want edge cases, overrides, and "unless..." patterns extracted specifically so that exception logic isn't lost in summarization.
|
| 153 |
+
12. As an Ops Owner, I want contradictions between SOPs and actual Slack/ticket behavior flagged so that I can identify and resolve policy drift.
|
| 154 |
+
13. As an Ops Owner, I want skills with confidence below 0.6 to be flagged for human review rather than auto-published so that only verified rules go live.
|
| 155 |
+
14. As an Ops Owner, I want each decision rule backlinked to its source file and excerpt so that every policy is auditable, not asserted.
|
| 156 |
+
15. As an Ops Owner, I want the system to retry once if the LLM returns malformed JSON so that a single bad LLM response doesn't abort the whole compile.
|
| 157 |
+
16. As an Ops Owner, I want a clear compile error message if vLLM becomes unreachable so that I know the issue is infrastructure, not my data.
|
| 158 |
+
17. As a Developer, I want LangGraph checkpointing via MemorySaver so that a crashed compile can be inspected and does not silently lose data.
|
| 159 |
+
|
| 160 |
+
### Skills File & Schema
|
| 161 |
+
|
| 162 |
+
18. As an Ops Owner, I want each skill stored as a versioned JSON object with id, name, domain, confidence, decision_logic, forbidden_actions, escalation_chain, and evidence_sources so that the schema is complete and consistent.
|
| 163 |
+
19. As an Ops Owner, I want skills converted to markdown at query time so that they are injected into LLM prompts efficiently.
|
| 164 |
+
20. As an Ops Owner, I want the meta block of the skills file to store source hashes so that stale skills can be detected when source files change.
|
| 165 |
+
21. As an Ops Owner, I want the skills file versioned with semver so that every compile produces a traceable snapshot.
|
| 166 |
+
|
| 167 |
+
### Version Management & Drift Detection
|
| 168 |
+
|
| 169 |
+
22. As an Ops Owner, I want to compare any two historical brain versions in a diff view so that I can see exactly what changed after a policy update.
|
| 170 |
+
23. As an Ops Owner, I want changed rules highlighted in the diff (added green, removed red, modified yellow) so that I don't have to read everything to spot changes.
|
| 171 |
+
24. As an Ops Owner, I want stale skills badged in the Skills Viewer so that I know which skills need recompilation after a source file changed.
|
| 172 |
+
25. As an Ops Owner, I want at least 2 pre-seeded historical versions in the demo so that the diff view is usable on day one.
|
| 173 |
+
|
| 174 |
+
### Brain Dashboard (Frontend)
|
| 175 |
+
|
| 176 |
+
26. As an Ops Owner, I want a "Build Company Brain" button on the dashboard so that I can trigger recompilation from the UI without touching a terminal.
|
| 177 |
+
27. As an Ops Owner, I want the button to be disabled with a spinner during an active compile so that I can't trigger duplicate jobs.
|
| 178 |
+
28. As an Ops Owner, I want a real-time SSE feed showing each pipeline node completing with timestamps so that I trust the system is working.
|
| 179 |
+
29. As an Ops Owner, I want the compilation time displayed to the second so that I can use this as a live AMD MI300X proof point.
|
| 180 |
+
30. As an Ops Owner, I want the current brain version and last-compiled timestamp visible at a glance so that I know which brain is active.
|
| 181 |
+
|
| 182 |
+
### Skills Viewer (Frontend)
|
| 183 |
+
|
| 184 |
+
31. As an Ops Owner, I want skills grouped by department so that I can navigate to the right area quickly.
|
| 185 |
+
32. As an Ops Owner, I want a visual confidence bar per skill so that I can immediately see which skills are strong vs. uncertain without reading numbers.
|
| 186 |
+
33. As an Ops Owner, I want to expand any skill and see all its decision conditions and forbidden actions so that I can verify the rules are correct.
|
| 187 |
+
34. As an Ops Owner, I want the evidence panel per skill to show source file names and excerpts so that I can trace every policy back to where it came from.
|
| 188 |
+
|
| 189 |
+
### Brain Agent (Demo)
|
| 190 |
+
|
| 191 |
+
35. As an AI Builder, I want to submit a natural language scenario to the brain agent so that I get a structured action recommendation with evidence, not a guess.
|
| 192 |
+
36. As an AI Builder, I want the response to include the exact rule condition that matched so that I can verify the logic is correct.
|
| 193 |
+
37. As an AI Builder, I want the agent to gracefully handle scenarios with no matching skill so that low-confidence situations escalate to a human rather than produce a wrong answer.
|
| 194 |
+
38. As a Demo Viewer / Judge, I want to see the agent without the brain respond generically to the same scenario so that the value of the compilation layer is immediately obvious.
|
| 195 |
+
39. As a Demo Viewer / Judge, I want to see the "Change a SOP rule → Rebuild → same scenario → different outcome" flow so that I understand this is a living map, not a static snapshot.
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## 6. Product Scope
|
| 200 |
+
|
| 201 |
+
### 6.1 Three-Ring Model
|
| 202 |
+
|
| 203 |
+
| Ring | Name | Timeline | What Ships |
|
| 204 |
+
|---|---|---|---|
|
| 205 |
+
| **Ring 1** | Hackathon v0 | May 4–10, 2026 | Offline compiler, Rivanly demo, file upload inputs, brain agent demo |
|
| 206 |
+
| **Ring 2** | Product v1 | 4–6 weeks post-hackathon | Live connectors, multi-tenant, real company data, auth |
|
| 207 |
+
| **Ring 3** | Scale | 2–6 months | Agent SDK, skills marketplace, audit trails, RBAC |
|
| 208 |
+
|
| 209 |
+
### 6.2 Hackathon v0 — In Scope
|
| 210 |
+
|
| 211 |
+
- Multi-agent compilation pipeline (LangGraph, 4 parallel async extraction agents)
|
| 212 |
+
- 6-department, 12-skill coverage of Rivanly Inc.
|
| 213 |
+
- Synthetic dataset (8 source files authored before kickoff)
|
| 214 |
+
- Skills file: JSON storage, markdown runtime, evidence-linked, confidence-scored, versioned
|
| 215 |
+
- Brain agent: scenario input → in-memory skill match → structured response with rule trace
|
| 216 |
+
- Frontend: Brain Dashboard + Skills Viewer + Demo Agent panel
|
| 217 |
+
- Real-time SSE compilation progress feed
|
| 218 |
+
- Brain version diffing (v1.2 → v1.3 what changed)
|
| 219 |
+
- AMD MI300X deployment via vLLM (`RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic`)
|
| 220 |
+
- Side-by-side "with brain vs. without brain" comparison panel — **P0, the money shot**
|
| 221 |
+
- Build in Public: 2 posts on X/LinkedIn during build
|
| 222 |
+
|
| 223 |
+
### 6.3 Out of Scope for v0
|
| 224 |
+
|
| 225 |
+
- Real Slack, Notion, Zendesk OAuth connectors — file upload only
|
| 226 |
+
- Multi-tenant isolation — single company demo
|
| 227 |
+
- Auth / login — none required for demo
|
| 228 |
+
- Redis job queue — direct `graph.ainvoke()` only
|
| 229 |
+
- pgvector — in-memory sentence-transformers for v0 skill matching
|
| 230 |
+
- Webhook-triggered recompilation
|
| 231 |
+
- Human skill review queue UI
|
| 232 |
+
|
| 233 |
+
### 6.4 Team Ownership
|
| 234 |
+
|
| 235 |
+
| Owner | Scope |
|
| 236 |
+
|---|---|
|
| 237 |
+
| **Abhijith** | F-01, F-02, F-03, F-04, F-05, F-06, F-07, F-12 (pipeline + API) |
|
| 238 |
+
| **Harshit** | F-08, F-09, F-10, F-11 (all frontend) |
|
| 239 |
+
| **Both** | Synthetic dataset — 4 files each, done before May 4 kickoff |
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## 7. Feature Requirements
|
| 244 |
+
|
| 245 |
+
Priority: **[P0]** = demo breaks without it · **[P1]** = must ship · **[P2]** = ship if time allows
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
### F-01: Source Ingestion [P0]
|
| 250 |
+
|
| 251 |
+
**Functional Requirements:**
|
| 252 |
+
- Accept `.md`, `.json`, `.txt` file uploads
|
| 253 |
+
- Parse Notion SOP markdown → `structured_sops[]`
|
| 254 |
+
- Parse Slack export JSON → `normalized_events[]`
|
| 255 |
+
- Parse ticket JSON → `resolved_cases[]`
|
| 256 |
+
- Compute SHA-256 hash per file; compare to previous run; skip unchanged files
|
| 257 |
+
- No LLM calls at this stage — pure Python parsing only
|
| 258 |
+
|
| 259 |
+
**Acceptance Criteria:**
|
| 260 |
+
|
| 261 |
+
*AC-01-1:*
|
| 262 |
+
- **Given** a valid Notion SOP `.md` file is uploaded
|
| 263 |
+
- **When** the ingest node runs
|
| 264 |
+
- **Then** `structured_sops` contains at least one entry with `source`, `content`, and `type` fields
|
| 265 |
+
|
| 266 |
+
*AC-01-2:*
|
| 267 |
+
- **Given** a file was uploaded in a previous compile with hash `H`
|
| 268 |
+
- **When** the same file is uploaded again unchanged
|
| 269 |
+
- **Then** the ingestion node skips extraction for that file and logs "hash match, skipping"
|
| 270 |
+
|
| 271 |
+
*AC-01-3:*
|
| 272 |
+
- **Given** a malformed JSON file is uploaded
|
| 273 |
+
- **When** the ingest node attempts to parse it
|
| 274 |
+
- **Then** the SSE stream emits `node_error` with `file`, `error: "parse_error"`, and `detail` — and the compile continues with remaining files
|
| 275 |
+
|
| 276 |
+
*AC-01-4:*
|
| 277 |
+
- **Given** an unsupported file type (e.g. `.xlsx`) is uploaded
|
| 278 |
+
- **When** `POST /sources/upload` is called
|
| 279 |
+
- **Then** the API returns `400` with `{"error": "unsupported_file_type", "accepted": [".md", ".json", ".txt"]}`
|
| 280 |
+
|
| 281 |
+
---
|
| 282 |
+
|
| 283 |
+
### F-02: Parallel Extraction [P0]
|
| 284 |
+
|
| 285 |
+
**Functional Requirements:**
|
| 286 |
+
- Four async LangGraph nodes run simultaneously via `Send` API + `await llm.ainvoke()`
|
| 287 |
+
- Decision Extractor: IF-THEN-EXCEPT judgment patterns from Slack + tickets
|
| 288 |
+
- Workflow Extractor: sequential process steps from SOPs and runbooks
|
| 289 |
+
- Exception Extractor: edge cases, overrides, "unless..." patterns
|
| 290 |
+
- Contradiction Detector: divergence between SOPs and actual behavior
|
| 291 |
+
- All four target `RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic` on AMD MI300X via vLLM
|
| 292 |
+
|
| 293 |
+
**Acceptance Criteria:**
|
| 294 |
+
|
| 295 |
+
*AC-02-1:*
|
| 296 |
+
- **Given** all three ingest nodes have completed
|
| 297 |
+
- **When** `route_to_extractors` is called
|
| 298 |
+
- **Then** all four extraction nodes start within 2 seconds of each other
|
| 299 |
+
|
| 300 |
+
*AC-02-2:*
|
| 301 |
+
- **Given** the Rivanly synthetic dataset
|
| 302 |
+
- **When** extraction completes
|
| 303 |
+
- **Then** each extractor returns a non-empty list; `contradictions[]` contains ≥ 1 entry
|
| 304 |
+
|
| 305 |
+
*AC-02-3:*
|
| 306 |
+
- **Given** the LLM returns malformed JSON for one extractor
|
| 307 |
+
- **When** the node catches the error
|
| 308 |
+
- **Then** it retries once with a stricter JSON-only prompt; if still malformed, returns empty list and emits `node_error` without aborting other extractors
|
| 309 |
+
|
| 310 |
+
*AC-02-4:*
|
| 311 |
+
- **Given** all four async extractors complete
|
| 312 |
+
- **When** wall clock is checked
|
| 313 |
+
- **Then** total extraction time is under 45 seconds on MI300X
|
| 314 |
+
|
| 315 |
+
---
|
| 316 |
+
|
| 317 |
+
### F-03: Skill Compilation [P0]
|
| 318 |
+
|
| 319 |
+
**Functional Requirements:**
|
| 320 |
+
- Synthesize extractor outputs into 12 canonical skill objects
|
| 321 |
+
- Evidence linker: backfill `evidence_sources[]` for every `decision_logic` entry
|
| 322 |
+
- Confidence scorer: `f(source_count, source_recency, internal_consistency)`
|
| 323 |
+
- Skills below 0.6 confidence: present with `"review_required": true`, not auto-published
|
| 324 |
+
- Write `skills_file.json` to Supabase `skills_files` table with incremented semver
|
| 325 |
+
|
| 326 |
+
**Acceptance Criteria:**
|
| 327 |
+
|
| 328 |
+
*AC-03-1:*
|
| 329 |
+
- **Given** all extraction nodes have produced output
|
| 330 |
+
- **When** `synthesize_skills` runs
|
| 331 |
+
- **Then** output contains exactly 12 skill objects, each with all required schema fields
|
| 332 |
+
|
| 333 |
+
*AC-03-2:*
|
| 334 |
+
- **Given** a skill has been synthesized
|
| 335 |
+
- **When** `link_evidence` runs
|
| 336 |
+
- **Then** every `decision_logic` entry has at least one `evidence_sources` entry with non-empty `source` and `excerpt`
|
| 337 |
+
|
| 338 |
+
*AC-03-3:*
|
| 339 |
+
- **Given** a skill has only one supporting source
|
| 340 |
+
- **When** `score_confidence` runs
|
| 341 |
+
- **Then** that skill's `confidence` is below 0.7 and `review_required` is `true` if below 0.6
|
| 342 |
+
|
| 343 |
+
*AC-03-4:*
|
| 344 |
+
- **Given** compilation succeeds
|
| 345 |
+
- **When** `write_brain` runs
|
| 346 |
+
- **Then** `skills_files` table gains a new row with semver one minor bump higher, `is_current: true`, and all previous rows `is_current: false`
|
| 347 |
+
|
| 348 |
+
---
|
| 349 |
+
|
| 350 |
+
### F-04: Skills File Format [P0]
|
| 351 |
+
|
| 352 |
+
**Schema (per skill):**
|
| 353 |
+
```json
|
| 354 |
+
{
|
| 355 |
+
"id": "handle_refund_request",
|
| 356 |
+
"name": "Handle Refund Request",
|
| 357 |
+
"domain": "support",
|
| 358 |
+
"version": "1.2",
|
| 359 |
+
"confidence": 0.91,
|
| 360 |
+
"stale": false,
|
| 361 |
+
"review_required": false,
|
| 362 |
+
"last_updated": "2026-05-04T09:30:00Z",
|
| 363 |
+
"trigger": {
|
| 364 |
+
"phrases": ["refund", "money back"],
|
| 365 |
+
"conditions": ["customer mentions payment dissatisfaction"]
|
| 366 |
+
},
|
| 367 |
+
"decision_logic": [
|
| 368 |
+
{
|
| 369 |
+
"condition": "plan == 'annual' AND days_since_purchase <= 14",
|
| 370 |
+
"action": "approve_full_refund",
|
| 371 |
+
"note": "No-questions policy within 14 days.",
|
| 372 |
+
"evidence_sources": [
|
| 373 |
+
{ "source": "notion_refund_sop.md", "excerpt": "...", "confidence": 0.95 }
|
| 374 |
+
]
|
| 375 |
+
}
|
| 376 |
+
],
|
| 377 |
+
"forbidden_actions": ["Never process refunds for lifetime deal accounts"],
|
| 378 |
+
"escalation_chain": ["support_agent", "support_lead", "account_manager", "founder"],
|
| 379 |
+
"sla": "respond_within_2h, resolve_within_24h"
|
| 380 |
+
}
|
| 381 |
+
```
|
| 382 |
+
|
| 383 |
+
**Acceptance Criteria:**
|
| 384 |
+
|
| 385 |
+
*AC-04-1:*
|
| 386 |
+
- **Given** the compiled skills file
|
| 387 |
+
- **When** validated against JSON schema
|
| 388 |
+
- **Then** zero validation errors
|
| 389 |
+
|
| 390 |
+
*AC-04-2:*
|
| 391 |
+
- **Given** a skill selected for prompt injection
|
| 392 |
+
- **When** converted to markdown
|
| 393 |
+
- **Then** output is plain English, contains all conditions and forbidden actions, under 800 tokens
|
| 394 |
+
|
| 395 |
+
---
|
| 396 |
+
|
| 397 |
+
### F-05: Brain Version Management [P1]
|
| 398 |
+
|
| 399 |
+
**Acceptance Criteria:**
|
| 400 |
+
|
| 401 |
+
*AC-05-1:*
|
| 402 |
+
- **Given** one source file changed and recompilation triggered
|
| 403 |
+
- **When** compile finishes
|
| 404 |
+
- **Then** new brain version is a minor bump (`1.2.0 → 1.3.0`) and diff endpoint returns that file's dependent skills as `modified_skills`
|
| 405 |
+
|
| 406 |
+
*AC-05-2:*
|
| 407 |
+
- **Given** two brain versions exist
|
| 408 |
+
- **When** `GET /diff/1.2.0/1.3.0` is called
|
| 409 |
+
- **Then** response contains `added_skills`, `removed_skills`, and `modified_skills` with per-skill field-level changes
|
| 410 |
+
|
| 411 |
+
*AC-05-3:*
|
| 412 |
+
- **Given** a source file changes
|
| 413 |
+
- **When** the new compile runs
|
| 414 |
+
- **Then** skills whose `evidence_sources` reference that file have `stale: true`
|
| 415 |
+
|
| 416 |
+
---
|
| 417 |
+
|
| 418 |
+
### F-06: Scenario Handling — Brain Agent [P0]
|
| 419 |
+
|
| 420 |
+
**Functional Requirements:**
|
| 421 |
+
- Accept natural language scenario input + optional structured context
|
| 422 |
+
- Embed query via `all-MiniLM-L6-v2` (in-memory, CPU); pre-compute skill embeddings once at startup
|
| 423 |
+
- Cosine similarity match → select top skill
|
| 424 |
+
- Convert skill JSON → markdown snippet
|
| 425 |
+
- Single LLM call: company context + skill rules + scenario
|
| 426 |
+
- Return structured response (F-07)
|
| 427 |
+
|
| 428 |
+
**Acceptance Criteria:**
|
| 429 |
+
|
| 430 |
+
*AC-06-1:*
|
| 431 |
+
- **Given** an enterprise refund scenario
|
| 432 |
+
- **When** `POST /agent/handle` is called
|
| 433 |
+
- **Then** matched skill is `handle_refund_request` (cosine similarity > 0.6)
|
| 434 |
+
|
| 435 |
+
*AC-06-2:*
|
| 436 |
+
- **Given** all 12 Rivanly demo scenarios submitted in sequence
|
| 437 |
+
- **When** each response reviewed
|
| 438 |
+
- **Then** all 12 return correct action (verified against ground truth table in Section 12)
|
| 439 |
+
|
| 440 |
+
*AC-06-3:*
|
| 441 |
+
- **Given** a scenario matching no skill above cosine 0.4
|
| 442 |
+
- **When** match function runs
|
| 443 |
+
- **Then** response is `{"action": "escalate_to_human", "reason": "no_skill_match", "confidence": <score>}` — not an error, not a hallucination
|
| 444 |
+
|
| 445 |
+
*AC-06-4:*
|
| 446 |
+
- **Given** a valid scenario submitted
|
| 447 |
+
- **When** response returned
|
| 448 |
+
- **Then** wall-clock latency under 8 seconds
|
| 449 |
+
|
| 450 |
+
---
|
| 451 |
+
|
| 452 |
+
### F-07: Response Structure [P0]
|
| 453 |
+
|
| 454 |
+
Every `POST /agent/handle` response:
|
| 455 |
+
|
| 456 |
+
```json
|
| 457 |
+
{
|
| 458 |
+
"action": "escalate_to_am_within_1hr",
|
| 459 |
+
"message_to_customer": "...",
|
| 460 |
+
"rule_applied": "plan == 'enterprise' AND any_amount",
|
| 461 |
+
"evidence": {
|
| 462 |
+
"source": "slack_thread_2024-03-12",
|
| 463 |
+
"excerpt": "enterprise = always AM"
|
| 464 |
+
},
|
| 465 |
+
"skill_matched": "handle_refund_request",
|
| 466 |
+
"confidence": 0.91
|
| 467 |
+
}
|
| 468 |
+
```
|
| 469 |
+
|
| 470 |
+
**Acceptance Criteria:**
|
| 471 |
+
|
| 472 |
+
*AC-07-1:*
|
| 473 |
+
- **Given** any valid scenario input
|
| 474 |
+
- **When** brain agent responds
|
| 475 |
+
- **Then** all six top-level fields present and non-null
|
| 476 |
+
|
| 477 |
+
*AC-07-2:*
|
| 478 |
+
- **Given** the response `rule_applied` field
|
| 479 |
+
- **When** compared to matched skill's `decision_logic`
|
| 480 |
+
- **Then** string matches an exact `condition` field — never paraphrased
|
| 481 |
+
|
| 482 |
+
---
|
| 483 |
+
|
| 484 |
+
### F-08: Brain Dashboard [P0]
|
| 485 |
+
|
| 486 |
+
**Acceptance Criteria:**
|
| 487 |
+
|
| 488 |
+
*AC-08-1:*
|
| 489 |
+
- **Given** the dashboard is loaded
|
| 490 |
+
- **When** user clicks "Build Company Brain"
|
| 491 |
+
- **Then** button becomes disabled with spinner and SSE feed begins showing node events within 2 seconds
|
| 492 |
+
|
| 493 |
+
*AC-08-2:*
|
| 494 |
+
- **Given** SSE stream is active
|
| 495 |
+
- **When** each pipeline node completes
|
| 496 |
+
- **Then** feed shows node name, completion checkmark, and elapsed time in real time without page refresh
|
| 497 |
+
|
| 498 |
+
*AC-08-3:*
|
| 499 |
+
- **Given** compilation completes
|
| 500 |
+
- **When** dashboard updates
|
| 501 |
+
- **Then** brain version, last-compiled timestamp, and total compilation time displayed with correct values
|
| 502 |
+
|
| 503 |
+
---
|
| 504 |
+
|
| 505 |
+
### F-09: Skills Viewer [P0]
|
| 506 |
+
|
| 507 |
+
**Acceptance Criteria:**
|
| 508 |
+
|
| 509 |
+
*AC-09-1:*
|
| 510 |
+
- **Given** a compiled brain with 12 skills
|
| 511 |
+
- **When** Skills Viewer loaded
|
| 512 |
+
- **Then** all 12 skills visible, grouped under 6 correct department headings
|
| 513 |
+
|
| 514 |
+
*AC-09-2:*
|
| 515 |
+
- **Given** a skill with `stale: true`
|
| 516 |
+
- **When** it appears in the viewer
|
| 517 |
+
- **Then** it has a visible "Stale" badge and confidence bar is visually de-emphasized
|
| 518 |
+
|
| 519 |
+
*AC-09-3:*
|
| 520 |
+
- **Given** user clicks any skill
|
| 521 |
+
- **When** detail panel expands
|
| 522 |
+
- **Then** all `decision_logic` conditions, all `forbidden_actions`, `escalation_chain`, and at least one `evidence_sources` entry visible
|
| 523 |
+
|
| 524 |
+
---
|
| 525 |
+
|
| 526 |
+
### F-10: Demo Agent Panel [P0] — Side-by-Side Required
|
| 527 |
+
|
| 528 |
+
**Functional Requirements:**
|
| 529 |
+
- Free-text scenario input + optional structured context fields
|
| 530 |
+
- Two response panels rendered simultaneously:
|
| 531 |
+
- **Without Brain:** Same LLM, same scenario, system prompt contains only the raw scenario — no company name, no skills context, no Rivanly-specific information. Goal: produce a demonstrably generic response.
|
| 532 |
+
- **With Brain:** Full skill context + rule trace + evidence
|
| 533 |
+
- Visual trace showing matched skill and cosine similarity score
|
| 534 |
+
|
| 535 |
+
**Acceptance Criteria:**
|
| 536 |
+
|
| 537 |
+
*AC-10-1:*
|
| 538 |
+
- **Given** a scenario submitted
|
| 539 |
+
- **When** both panels render
|
| 540 |
+
- **Then** both responses appear within 10 seconds
|
| 541 |
+
|
| 542 |
+
*AC-10-2:*
|
| 543 |
+
- **Given** the enterprise refund demo scenario
|
| 544 |
+
- **When** both panels render
|
| 545 |
+
- **Then** "Without Brain" response is generic (no company-specific rule, no evidence); "With Brain" response includes `rule_applied` and `evidence` visually highlighted
|
| 546 |
+
|
| 547 |
+
*AC-10-3:*
|
| 548 |
+
- **Given** a judge views the demo panel for the first time
|
| 549 |
+
- **When** they read both responses
|
| 550 |
+
- **Then** the value of the brain is legible without any verbal explanation
|
| 551 |
+
|
| 552 |
+
---
|
| 553 |
+
|
| 554 |
+
### F-11: Version Diff View [P1]
|
| 555 |
+
|
| 556 |
+
**Acceptance Criteria:**
|
| 557 |
+
|
| 558 |
+
*AC-11-1:*
|
| 559 |
+
- **Given** two pre-seeded brain versions (v1.1.0 and v1.2.0)
|
| 560 |
+
- **When** diff view opened and both selected
|
| 561 |
+
- **Then** modified skills highlighted yellow with field-level changes inline
|
| 562 |
+
|
| 563 |
+
*AC-11-2:*
|
| 564 |
+
- **Given** the "change a rule → rebuild → diff" demo flow
|
| 565 |
+
- **When** performed end-to-end
|
| 566 |
+
- **Then** diff correctly shows changed rule in under 30 seconds of demo time
|
| 567 |
+
|
| 568 |
+
---
|
| 569 |
+
|
| 570 |
+
### F-12: API Layer [P0]
|
| 571 |
+
|
| 572 |
+
**`POST /compile`**
|
| 573 |
+
```
|
| 574 |
+
Request: { "company_id": "rivanly-inc", "force_recompile": false }
|
| 575 |
+
Response: { "job_id": "uuid", "status": "started", "stream_url": "/compile/stream?job_id=uuid" }
|
| 576 |
+
```
|
| 577 |
+
|
| 578 |
+
**`GET /brain/status`**
|
| 579 |
+
```
|
| 580 |
+
Response: {
|
| 581 |
+
"company_id": "rivanly-inc",
|
| 582 |
+
"brain_version": "1.3.0",
|
| 583 |
+
"last_compiled_at": "2026-05-04T09:30:00Z",
|
| 584 |
+
"total_skills": 12,
|
| 585 |
+
"stale_skills": 2,
|
| 586 |
+
"coverage_areas": ["support", "revenue", "product_eng", "customer_success", "hr", "finance_ops"]
|
| 587 |
+
}
|
| 588 |
+
```
|
| 589 |
+
|
| 590 |
+
**`GET /skills`**
|
| 591 |
+
```
|
| 592 |
+
Response: {
|
| 593 |
+
"skills": [
|
| 594 |
+
{ "id": "handle_refund_request", "name": "Handle Refund Request",
|
| 595 |
+
"domain": "support", "confidence": 0.91, "stale": false, "version": "1.2" }
|
| 596 |
+
]
|
| 597 |
+
}
|
| 598 |
+
```
|
| 599 |
+
|
| 600 |
+
**`GET /skills/:id`** → full skill object (schema per F-04)
|
| 601 |
+
|
| 602 |
+
**`POST /agent/handle`**
|
| 603 |
+
```
|
| 604 |
+
Request: {
|
| 605 |
+
"scenario": "Enterprise customer, 18 months tenure, wants $1,200 refund",
|
| 606 |
+
"context": { "plan": "enterprise", "tenure_months": 18, "refund_amount": 1200 },
|
| 607 |
+
"with_brain": true
|
| 608 |
+
}
|
| 609 |
+
```
|
| 610 |
+
|
| 611 |
+
**`with_brain` flag behaviour (fully specified):**
|
| 612 |
+
- `with_brain: true` → system prompt includes: company name (Rivanly), active brain version, relevant skill in markdown, all decision conditions, forbidden actions, escalation chain
|
| 613 |
+
- `with_brain: false` → system prompt contains ONLY the raw scenario text. No company name. No skills context. No Rivanly-specific information. No hint that a brain exists. The goal is to produce a generic response from the base model that demonstrates what agents do WITHOUT the compilation layer. This is the "before" panel in the side-by-side comparison.
|
| 614 |
+
|
| 615 |
+
**`GET /compile/stream?job_id=uuid`** — SSE event schema:
|
| 616 |
+
```
|
| 617 |
+
event: node_start
|
| 618 |
+
data: {"node": "ingest_slack", "timestamp": "2026-05-04T09:30:01Z"}
|
| 619 |
+
|
| 620 |
+
event: node_complete
|
| 621 |
+
data: {"node": "ingest_slack", "duration_ms": 312, "output_count": 47}
|
| 622 |
+
|
| 623 |
+
event: node_error
|
| 624 |
+
data: {"node": "extract_decisions", "error": "llm_malformed_json", "retrying": true}
|
| 625 |
+
|
| 626 |
+
event: compile_complete
|
| 627 |
+
data: {
|
| 628 |
+
"brain_version": "1.3.0",
|
| 629 |
+
"total_skills": 12,
|
| 630 |
+
"stale_skills": 0,
|
| 631 |
+
"duration_ms": 54200,
|
| 632 |
+
"skills_below_threshold": 1
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
event: compile_error
|
| 636 |
+
data: {"error": "llm_unavailable", "checkpoint_saved": true, "resume_job_id": "uuid"}
|
| 637 |
+
```
|
| 638 |
+
|
| 639 |
+
**`GET /diff/:v1/:v2`**
|
| 640 |
+
```
|
| 641 |
+
Response: {
|
| 642 |
+
"from_version": "1.2.0", "to_version": "1.3.0",
|
| 643 |
+
"added_skills": [], "removed_skills": [],
|
| 644 |
+
"modified_skills": [
|
| 645 |
+
{ "id": "handle_refund_request",
|
| 646 |
+
"changes": [{"field": "decision_logic[1].action",
|
| 647 |
+
"from": "approve_prorated_refund", "to": "escalate_to_am"}] }
|
| 648 |
+
]
|
| 649 |
+
}
|
| 650 |
+
```
|
| 651 |
+
|
| 652 |
+
**`POST /sources/upload`**
|
| 653 |
+
```
|
| 654 |
+
Request: multipart/form-data: files[], company_id
|
| 655 |
+
Response: { "uploaded": ["notion_refund_sop.md"], "hashes": {"notion_refund_sop.md": "sha256:a1b2c3..."} }
|
| 656 |
+
```
|
| 657 |
+
|
| 658 |
+
---
|
| 659 |
+
|
| 660 |
+
## 8. AI System Requirements
|
| 661 |
+
|
| 662 |
+
### 8.1 Tool & Model Requirements
|
| 663 |
+
|
| 664 |
+
| Component | Tool / Model | Reason |
|
| 665 |
+
|---|---|---|
|
| 666 |
+
| All LLM extraction calls | `RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic` via vLLM | Best instruction following; FP8 = 1.5× throughput, ~72GB VRAM |
|
| 667 |
+
| Skill matching (v0) | `all-MiniLM-L6-v2` via `sentence-transformers` (in-memory, CPU) | Zero infra overhead; sufficient for 12 skills |
|
| 668 |
+
| Skill matching (v1) | pgvector on Supabase | Multi-tenant, persistent, scalable |
|
| 669 |
+
| LLM fallback | `Llama-3.3-70B BF16` | If Qwen2.5 unavailable on MI300X |
|
| 670 |
+
| Serving | vLLM on AMD MI300X (192GB VRAM) | Parallel batch inference, OpenAI-compatible API |
|
| 671 |
+
|
| 672 |
+
### 8.2 Extraction Prompts — Requirements
|
| 673 |
+
|
| 674 |
+
- All extractors demand: "Output ONLY structured JSON. Do not summarize. Do not generalize beyond what the text explicitly supports."
|
| 675 |
+
- All extractors include: output schema definition + 1-shot example in system prompt
|
| 676 |
+
- Temperature: 0.1 (deterministic extraction)
|
| 677 |
+
- Max tokens: 4096 per call
|
| 678 |
+
|
| 679 |
+
### 8.3 Evaluation Strategy
|
| 680 |
+
|
| 681 |
+
| Eval | Target | How to test |
|
| 682 |
+
|---|---|---|
|
| 683 |
+
| Brain agent correct action | 12 / 12 (100%) | Run all 12 scenarios against ground truth table |
|
| 684 |
+
| Evidence coverage | 100% of `decision_logic` entries have ≥ 1 `evidence_sources` | JSON schema validation post-compile |
|
| 685 |
+
| Contradiction recall | ≥ 2 contradictions flagged | Plant 2 deliberate contradictions in synthetic dataset |
|
| 686 |
+
| Confidence calibration | Well-sourced skills ≥ 0.7, single-source skills < 0.75 | Inspect post-compile |
|
| 687 |
+
| LLM JSON validity | 0 uncaught malformed responses in 10-run stress test | Run compile 10× on same dataset |
|
| 688 |
+
| "Without brain" failure rate | ≥ 8 of 12 scenarios produce generic/wrong response | Verify demo panel contrast is meaningful |
|
| 689 |
+
|
| 690 |
+
---
|
| 691 |
+
|
| 692 |
+
## 9. Implementation Decisions
|
| 693 |
+
|
| 694 |
+
**`with_brain: false` — Full Specification**
|
| 695 |
+
|
| 696 |
+
When `POST /agent/handle` is called with `"with_brain": false`, the system prompt sent to Qwen2.5-72B contains ONLY this:
|
| 697 |
+
|
| 698 |
+
```
|
| 699 |
+
You are a helpful customer support assistant.
|
| 700 |
+
The customer says: {scenario}
|
| 701 |
+
{context if provided}
|
| 702 |
+
Respond appropriately.
|
| 703 |
+
```
|
| 704 |
+
|
| 705 |
+
No company name. No skills. No Rivanly. No hint that any compiled knowledge exists. The base model responds from its training data alone. This produces a generic, policy-free response — which is the "before" state that makes the "with brain" response look like magic.
|
| 706 |
+
|
| 707 |
+
**Other key architectural decisions:**
|
| 708 |
+
|
| 709 |
+
- `ingest_join` + `Send` API pattern required for correct LangGraph fan-in. Direct edges ingest→extract cause synthesis to fire before all extractors complete.
|
| 710 |
+
- `Annotated[List, operator.add]` on all extraction output fields required for parallel writes to merge correctly rather than overwrite.
|
| 711 |
+
- `await compiled_graph.ainvoke(initial_state)` — not `.invoke()` — in FastAPI background task. Without async, nodes block the event loop and parallelism is lost.
|
| 712 |
+
- Skills file is the only source of truth. The agent never reads raw source files at query time.
|
| 713 |
+
- `skills_files.is_current` enforced via partial unique index — only one row per company can be `true` at a time.
|
| 714 |
+
- `compile_runs` table is append-only. No updates.
|
| 715 |
+
|
| 716 |
+
---
|
| 717 |
+
|
| 718 |
+
## 10. Technical Specifications
|
| 719 |
+
|
| 720 |
+
### 10.1 Architecture Overview
|
| 721 |
+
|
| 722 |
+
```
|
| 723 |
+
FILE UPLOAD (Next.js)
|
| 724 |
+
│
|
| 725 |
+
▼ POST /sources/upload
|
| 726 |
+
FASTAPI API LAYER
|
| 727 |
+
│
|
| 728 |
+
▼ POST /compile → ainvoke
|
| 729 |
+
LANGGRAPH ENGINE (BrainState)
|
| 730 |
+
│
|
| 731 |
+
├── INGESTION (parallel, CPU)
|
| 732 |
+
│ ├── ingest_slack → normalized_events[]
|
| 733 |
+
│ ├── ingest_notion → structured_sops[]
|
| 734 |
+
│ └── ingest_tickets → resolved_cases[]
|
| 735 |
+
│ └── ingest_join (barrier)
|
| 736 |
+
│
|
| 737 |
+
├── EXTRACTION (parallel, async, AMD MI300X)
|
| 738 |
+
│ ├── extract_decisions → raw_decisions[]
|
| 739 |
+
│ ├── extract_workflows → workflow_steps[]
|
| 740 |
+
│ ├── extract_exceptions → exception_rules[]
|
| 741 |
+
│ └── detect_contradictions → contradictions[]
|
| 742 |
+
│
|
| 743 |
+
└── COMPILATION + VALIDATION (sequential)
|
| 744 |
+
├── synthesize_skills → draft_skills[]
|
| 745 |
+
├── link_evidence → skills_with_evidence[]
|
| 746 |
+
├── score_confidence → confidence per skill
|
| 747 |
+
└── write_brain → skills_file.json → Supabase
|
| 748 |
+
|
| 749 |
+
BRAIN AGENT (query time)
|
| 750 |
+
POST /agent/handle
|
| 751 |
+
→ sentence-transformers match → skill JSON → markdown
|
| 752 |
+
→ single vLLM call → structured response JSON
|
| 753 |
+
```
|
| 754 |
+
|
| 755 |
+
### 10.2 Screen-to-Screen User Flow
|
| 756 |
+
|
| 757 |
+
**Primary flow (Ops Owner compiling and testing for the first time):**
|
| 758 |
+
|
| 759 |
+
```
|
| 760 |
+
[Upload Sources page]
|
| 761 |
+
→ Upload 3–8 files (drag + drop or file picker)
|
| 762 |
+
→ See file list with SHA-256 hash status (new / unchanged / changed)
|
| 763 |
+
→ Click "Done — Go to Dashboard"
|
| 764 |
+
↓
|
| 765 |
+
[Brain Dashboard]
|
| 766 |
+
→ See: company name, current brain version (or "No brain yet"), last compiled timestamp
|
| 767 |
+
→ See: source files uploaded (count)
|
| 768 |
+
→ Click "Build Company Brain" button
|
| 769 |
+
↓
|
| 770 |
+
[SSE Progress overlay — renders in-place on Dashboard]
|
| 771 |
+
→ Real-time: each node appears as it starts, gets checkmark when complete
|
| 772 |
+
→ ingest_slack ✓ → ingest_notion ✓ → ingest_tickets ✓ → [join]
|
| 773 |
+
→ extract_decisions ✓ (parallel) extract_workflows ✓ extract_exceptions ✓ detect_contradictions ✓
|
| 774 |
+
→ synthesize_skills ✓ → link_evidence ✓ → score_confidence ✓ → write_brain ✓
|
| 775 |
+
→ "Brain compiled: v1.3.0 in 58 seconds"
|
| 776 |
+
↓
|
| 777 |
+
[Brain Dashboard — updated state]
|
| 778 |
+
→ Version badge updated: v1.3.0
|
| 779 |
+
→ Last compiled: just now
|
| 780 |
+
→ 12 skills / 6 departments / 0 stale
|
| 781 |
+
→ Click "View Skills" (or nav to Skills in sidebar)
|
| 782 |
+
↓
|
| 783 |
+
[Skills Viewer]
|
| 784 |
+
→ 6 department groups, 12 skill cards
|
| 785 |
+
→ Each card: name, confidence bar, stale badge (if applicable)
|
| 786 |
+
→ Click any skill card → detail panel expands right
|
| 787 |
+
→ Detail: all conditions, forbidden actions, escalation chain, evidence panel (source + excerpt)
|
| 788 |
+
→ Click "Try a scenario" button (appears in detail panel)
|
| 789 |
+
↓
|
| 790 |
+
[Demo Agent Panel]
|
| 791 |
+
→ Left panel: "Without Brain" — base model response (generic)
|
| 792 |
+
→ Right panel: "With Brain" — rule trace + evidence + action
|
| 793 |
+
→ Scenario input pre-filled from skill that was clicked (optional convenience)
|
| 794 |
+
→ Submit → both panels render simultaneously
|
| 795 |
+
→ Judge reads both — value is self-evident
|
| 796 |
+
→ Click "What changed?" link (appears in top nav after ≥ 2 brain versions exist)
|
| 797 |
+
↓
|
| 798 |
+
[Version Diff View]
|
| 799 |
+
→ Select v1 and v2 from dropdowns (pre-seeded with v1.1.0 and v1.2.0)
|
| 800 |
+
→ See: modified skills (yellow), new skills (green), removed (red)
|
| 801 |
+
→ Click modified skill → see field-level diff of changed conditions
|
| 802 |
+
→ Click "← Back to Dashboard" (always accessible from nav)
|
| 803 |
+
↓
|
| 804 |
+
[Brain Dashboard]
|
| 805 |
+
→ Modify a source file → re-upload → stale badge appears on affected skills
|
| 806 |
+
→ Click "Build Company Brain" → recompile cycle repeats
|
| 807 |
+
```
|
| 808 |
+
|
| 809 |
+
**Critical path for demo (8-step script):**
|
| 810 |
+
Upload Sources → Dashboard → Build → SSE feed → Skills Viewer → Evidence panel → Demo Agent (side-by-side) → Change + Rebuild → Diff view
|
| 811 |
+
|
| 812 |
+
**Navigation rules:**
|
| 813 |
+
- Sidebar always visible: Dashboard | Skills | Agent | Diff
|
| 814 |
+
- "Try a scenario" shortcut from Skills Viewer pre-fills the Agent panel's skill context
|
| 815 |
+
- "What changed?" link only appears when ≥ 2 brain versions exist (prevents confusion when first compiled)
|
| 816 |
+
- All pages accessible from nav at any time — no forced linear flow outside the demo script
|
| 817 |
+
|
| 818 |
+
### 10.3 Integration Points
|
| 819 |
+
|
| 820 |
+
| Integration | v0 | v1 |
|
| 821 |
+
|---|---|---|
|
| 822 |
+
| LLM | vLLM on AMD MI300X (private IP:8000) | Same + failover |
|
| 823 |
+
| Database | Supabase Postgres | Same + RLS per company |
|
| 824 |
+
| File storage | Supabase Storage | Same |
|
| 825 |
+
| Auth | None | Clerk |
|
| 826 |
+
| Queue | None (direct ainvoke) | Redis/Upstash |
|
| 827 |
+
| Connectors | File upload only | Slack OAuth, Notion API, Zendesk |
|
| 828 |
+
| Checkpointing | MemorySaver (in-memory) | PostgresSaver |
|
| 829 |
+
|
| 830 |
+
### 10.4 Security & Privacy
|
| 831 |
+
|
| 832 |
+
**v0 (hackathon):** All data is synthetic (Rivanly is fictional). No PII. vLLM on private AMD cloud IP. No RLS needed.
|
| 833 |
+
|
| 834 |
+
**v1 (required before real customer data):**
|
| 835 |
+
- Clerk auth on all endpoints
|
| 836 |
+
- Supabase RLS: `company_id` row-level isolation
|
| 837 |
+
- vLLM behind VPC — not publicly accessible
|
| 838 |
+
- No customer message content stored permanently — only extracted rules and evidence excerpts
|
| 839 |
+
|
| 840 |
+
---
|
| 841 |
+
|
| 842 |
+
## 11. Data Model (Supabase)
|
| 843 |
+
|
| 844 |
+
```sql
|
| 845 |
+
CREATE TABLE companies (
|
| 846 |
+
id TEXT PRIMARY KEY,
|
| 847 |
+
name TEXT NOT NULL,
|
| 848 |
+
created_at TIMESTAMPTZ DEFAULT now()
|
| 849 |
+
);
|
| 850 |
+
|
| 851 |
+
CREATE TABLE skills_files (
|
| 852 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 853 |
+
company_id TEXT REFERENCES companies(id),
|
| 854 |
+
version TEXT NOT NULL,
|
| 855 |
+
brain_json JSONB NOT NULL,
|
| 856 |
+
source_hashes JSONB NOT NULL,
|
| 857 |
+
compiled_at TIMESTAMPTZ DEFAULT now(),
|
| 858 |
+
is_current BOOLEAN DEFAULT false
|
| 859 |
+
);
|
| 860 |
+
CREATE UNIQUE INDEX idx_skills_files_current ON skills_files(company_id) WHERE is_current = true;
|
| 861 |
+
|
| 862 |
+
CREATE TABLE skills (
|
| 863 |
+
id TEXT NOT NULL,
|
| 864 |
+
company_id TEXT REFERENCES companies(id),
|
| 865 |
+
skills_file_id UUID REFERENCES skills_files(id),
|
| 866 |
+
name TEXT NOT NULL,
|
| 867 |
+
domain TEXT NOT NULL,
|
| 868 |
+
version TEXT NOT NULL,
|
| 869 |
+
confidence FLOAT NOT NULL,
|
| 870 |
+
stale BOOLEAN DEFAULT false,
|
| 871 |
+
review_required BOOLEAN DEFAULT false,
|
| 872 |
+
skill_json JSONB NOT NULL,
|
| 873 |
+
PRIMARY KEY (id, company_id, skills_file_id)
|
| 874 |
+
);
|
| 875 |
+
|
| 876 |
+
CREATE TABLE source_files (
|
| 877 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 878 |
+
company_id TEXT REFERENCES companies(id),
|
| 879 |
+
filename TEXT NOT NULL,
|
| 880 |
+
sha256 TEXT NOT NULL,
|
| 881 |
+
storage_path TEXT NOT NULL,
|
| 882 |
+
uploaded_at TIMESTAMPTZ DEFAULT now()
|
| 883 |
+
);
|
| 884 |
+
|
| 885 |
+
CREATE TABLE compile_runs (
|
| 886 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 887 |
+
company_id TEXT REFERENCES companies(id),
|
| 888 |
+
status TEXT NOT NULL CHECK (status IN ('started','running','complete','error')),
|
| 889 |
+
started_at TIMESTAMPTZ DEFAULT now(),
|
| 890 |
+
completed_at TIMESTAMPTZ,
|
| 891 |
+
duration_ms INTEGER,
|
| 892 |
+
result_version TEXT,
|
| 893 |
+
error_detail TEXT
|
| 894 |
+
);
|
| 895 |
+
|
| 896 |
+
CREATE INDEX idx_skills_files_company ON skills_files(company_id, compiled_at DESC);
|
| 897 |
+
CREATE INDEX idx_skills_company ON skills(company_id);
|
| 898 |
+
```
|
| 899 |
+
|
| 900 |
+
---
|
| 901 |
+
|
| 902 |
+
## 12. Testing Decisions
|
| 903 |
+
|
| 904 |
+
### Ground Truth Test Suite — All 12 Scenarios (COMPLETE)
|
| 905 |
+
|
| 906 |
+
**Owner: Abhijith. Must be run and passing before demo day. All 12 must return correct `action`.**
|
| 907 |
+
|
| 908 |
+
| # | Scenario | Key Context | Expected `action` | Expected `skill_matched` |
|
| 909 |
+
|---|---|---|---|---|
|
| 910 |
+
| 1 | Enterprise customer, 18 months tenure, $1,200 refund requested | plan=enterprise, tenure=18mo, amount=1200 | `escalate_to_am_within_1hr` | `handle_refund_request` |
|
| 911 |
+
| 2 | Annual plan customer, day 10 of subscription, $300 refund requested | plan=annual, days_since_purchase=10, amount=300 | `approve_full_refund` | `handle_refund_request` |
|
| 912 |
+
| 3 | New customer, 2 months tenure, $600 refund requested | plan=monthly, tenure=2mo, amount=600 | `escalate_to_founder` | `handle_refund_request` |
|
| 913 |
+
| 4 | Loyal annual customer, 14 months tenure, $150 refund outside window | plan=annual, tenure=14mo, amount=150 | `approve_prorated_refund` | `handle_refund_request` |
|
| 914 |
+
| 5 | Lifetime deal customer requesting any refund | plan=lifetime, amount=any | `deny_refund_ltd_terms` | `handle_refund_request` |
|
| 915 |
+
| 6 | Customer contact during active platform outage | context=outage_active | `send_incident_response_template` | `respond_to_outage` |
|
| 916 |
+
| 7 | Startup customer requesting 40% discount | customer_type=startup, discount_requested=40% | `escalate_to_ae` | `evaluate_discount_request` |
|
| 917 |
+
| 8 | P0 bug reported on dashboard module by enterprise customer | bug_severity=P0, customer_plan=enterprise | `page_oncall_engineer_immediately` | `prioritize_bug_report` |
|
| 918 |
+
| 9 | Customer SLA breached by 2 hours, enterprise plan | sla_breach_hours=2, plan=enterprise | `notify_am_and_eng_lead` | `handle_sla_breach` |
|
| 919 |
+
| 10 | Customer showing 3 churn signals in last 30 days (no login, support ticket, downgrade inquiry) | signals=3, timeframe=30days | `schedule_am_call_within_24h` | `evaluate_churn_risk` |
|
| 920 |
+
| 11 | Engineering candidate — completed 2 rounds, needs offer approval | stage=offer, role=engineer | `get_founder_approval_before_sending` | `hiring_process_engineering` |
|
| 921 |
+
| 12 | Vendor invoice for $3,500 needs payment approval | amount=3500, vendor_type=software | `route_to_ops_lead_approval` | `approve_vendor_payment` |
|
| 922 |
+
|
| 923 |
+
**How to run:**
|
| 924 |
+
```python
|
| 925 |
+
# Run before demo. All 12 must pass.
|
| 926 |
+
for scenario in GROUND_TRUTH_SCENARIOS:
|
| 927 |
+
response = client.post("/agent/handle", json=scenario["input"])
|
| 928 |
+
assert response.json()["action"] == scenario["expected_action"]
|
| 929 |
+
assert response.json()["skill_matched"] == scenario["expected_skill"]
|
| 930 |
+
```
|
| 931 |
+
|
| 932 |
+
### Module Test Matrix
|
| 933 |
+
|
| 934 |
+
| Module | Test Type | What to Test |
|
| 935 |
+
|---|---|---|
|
| 936 |
+
| Source parsers | Unit | Given raw fixture file → correct normalized output shape |
|
| 937 |
+
| SHA-256 hasher | Unit | Same content → same hash; changed content → different hash |
|
| 938 |
+
| Skill matcher | Unit | Given 12 known queries → each returns correct `skill_id` |
|
| 939 |
+
| JSON→Markdown converter | Unit | Given skill object → output contains all conditions and forbidden actions, under 800 tokens |
|
| 940 |
+
| `POST /compile` | Integration | Returns `job_id` and `stream_url`; sets compile_run status to "started" |
|
| 941 |
+
| `GET /skills` | Integration | Returns exactly 12 skills for Rivanly |
|
| 942 |
+
| `POST /agent/handle` | Integration | All 12 ground-truth scenarios return correct `action` |
|
| 943 |
+
| `GET /diff/:v1/:v2` | Integration | Pre-seeded v1.1.0 and v1.2.0 → returns expected `modified_skills` |
|
| 944 |
+
| Full pipeline | End-to-end | 8 source files → 12 skills in Supabase, `is_current: true`, all with evidence |
|
| 945 |
+
| LLM output | Eval | 10-run stress test → zero uncaught malformed JSON |
|
| 946 |
+
|
| 947 |
+
---
|
| 948 |
+
|
| 949 |
+
## 13. Non-Functional Requirements
|
| 950 |
+
|
| 951 |
+
- Full compilation: under 90 seconds (target: 60s)
|
| 952 |
+
- Brain agent response: under 8 seconds
|
| 953 |
+
- SSE feed: real-time node events, no polling
|
| 954 |
+
- Skill matching: under 200ms (in-memory cosine similarity)
|
| 955 |
+
- LangGraph MemorySaver checkpointing: compile state survives crash
|
| 956 |
+
- Fallback model: Llama-3.3-70B BF16 if Qwen2.5 unavailable
|
| 957 |
+
- vLLM health check queried before accepting `/compile` requests
|
| 958 |
+
|
| 959 |
+
---
|
| 960 |
+
|
| 961 |
+
## 14. Success Metrics
|
| 962 |
+
|
| 963 |
+
### Hackathon v0 — Measurable Targets
|
| 964 |
+
|
| 965 |
+
| Metric | Target | Verification |
|
| 966 |
+
|---|---|---|
|
| 967 |
+
| End-to-end pipeline | Completes without error | Run 3× in final 2 hours |
|
| 968 |
+
| Skills produced | Exactly 12 | Check `skills_file.json` |
|
| 969 |
+
| Skills with confidence ≥ 0.7 | ≥ 10 of 12 | Check confidence field |
|
| 970 |
+
| Agent correct action | 12 / 12 | Run ground truth suite |
|
| 971 |
+
| Agent latency | < 8 seconds | Time on demo day |
|
| 972 |
+
| Compilation time | < 90 seconds | Dashboard display |
|
| 973 |
+
| Live URL accessible | Yes | Test on fresh device before submission |
|
| 974 |
+
| Demo video submitted | Yes | Render early, keep backup |
|
| 975 |
+
| Public posts | 2 minimum | During hours 8–16 and 16–28 |
|
| 976 |
+
|
| 977 |
+
### The 8-Step Demo — Ring 1 Acceptance Test
|
| 978 |
+
|
| 979 |
+
1. Show source files — "Rivanly's scattered knowledge."
|
| 980 |
+
2. Click "Build Company Brain" — watch SSE feed in real time.
|
| 981 |
+
3. Show compilation time — "12 skills in 58 seconds on AMD MI300X."
|
| 982 |
+
4. Open Skills Viewer — 6 departments, 12 skills, confidence bars.
|
| 983 |
+
5. Click `handle_refund_request` — show evidence panel.
|
| 984 |
+
6. Submit enterprise refund scenario to agent panel.
|
| 985 |
+
7. Show side-by-side: without brain (generic) vs. with brain (rule trace + evidence).
|
| 986 |
+
8. Change one SOP rule → Rebuild → same scenario → different outcome. **This is the moment.**
|
| 987 |
+
|
| 988 |
+
### Post-Hackathon Business Metrics (v1)
|
| 989 |
+
|
| 990 |
+
- 3 paying pilot customers within 60 days of v1 launch
|
| 991 |
+
- Activation: first brain compiled + agent handles 1 scenario correctly
|
| 992 |
+
- Retention: brain recompiled at least once within 30 days
|
| 993 |
+
- Revenue: $200/month Starter, $500/month Growth
|
| 994 |
+
|
| 995 |
+
---
|
| 996 |
+
|
| 997 |
+
## 15. Competitive Landscape
|
| 998 |
+
|
| 999 |
+
*Updated with companies identified in YC/LinkedIn Company Brain thread.*
|
| 1000 |
+
|
| 1001 |
+
| Company | What they do | Differentiation |
|
| 1002 |
+
|---|---|---|
|
| 1003 |
+
| **Notion AI** | Q&A over documents | Retrieves chunks, doesn't compile operational judgment |
|
| 1004 |
+
| **Guru / Confluence** | Knowledge base search | Human-maintained, not executable by AI agents |
|
| 1005 |
+
| **Glean** | Enterprise search | Search-first, not compilation; no executable output |
|
| 1006 |
+
| **Sugarwork** (sugarwork.com) | Surfaces tacit knowledge for AI | Adjacent; watch closely |
|
| 1007 |
+
| **BrandOS** (getbrandos.site) | Company brain for marketing teams | Vertical-specific; not full company coverage |
|
| 1008 |
+
| **Context AI** | Operational knowledge for agents | Direct competitor — monitor |
|
| 1009 |
+
| **LineageOne** (NEXT'26) | Fragmented operations → live operational model | Direct competitor |
|
| 1010 |
+
| **AutoBase** | Building this for 7 months | Direct competitor |
|
| 1011 |
+
| **Company Brain** | Full compilation layer, all departments, versioned, evidence-linked | Evidence trail, stale detection, parallel AMD compilation |
|
| 1012 |
+
|
| 1013 |
+
**Observation:** Multiple teams are building in this space. This validates the market. The race is to who ships the most complete, demo-able, production-credible version. Company Brain's differentiator is the combination of: evidence-linked rules (not just structured outputs), stale detection, version diffing, and the clean "compiler not assistant" framing that competitors haven't articulated.
|
| 1014 |
+
|
| 1015 |
+
---
|
| 1016 |
+
|
| 1017 |
+
## 16. Risks & Mitigation
|
| 1018 |
+
|
| 1019 |
+
| Risk | Likelihood | Mitigation |
|
| 1020 |
+
|---|---|---|
|
| 1021 |
+
| Knowledge that was never captured cannot be extracted | **High — acknowledged** | Scope v0 to knowledge that exists in digital form; call out in pitch as known limitation; v1 adds call transcription |
|
| 1022 |
+
| Extraction agents produce low-quality skills | Medium | Dataset authored backward from desired output; eval suite catches failures before demo |
|
| 1023 |
+
| vLLM setup on AMD cloud takes too long | Low | Kubernetes on AMD course completed; fallback to Fireworks API |
|
| 1024 |
+
| LangGraph parallel fan-in bug | Low | Fixed using `Send` API + `ingest_join` barrier node |
|
| 1025 |
+
| Demo breaks during judging | Medium | Pre-recorded fallback video; deploy to stable URL 24h before submission |
|
| 1026 |
+
| Qwen2.5-72B FP8 unavailable | Low | `RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic` confirmed on HuggingFace |
|
| 1027 |
+
| Frontend/backend API contract mismatch | Medium | Both parties agree on F-12 schemas before writing frontend code |
|
| 1028 |
+
| Synthetic dataset too shallow | Medium | Each file: ≥ 4 edge cases, ≥ 1 planted contradiction; reviewed together before kickoff |
|
| 1029 |
+
| Competitors ship demo before May 11 | Low | Multiple are building but none have shipped a demo yet; Company Brain's AMD + parallel compile angle is unique |
|
| 1030 |
+
|
| 1031 |
+
---
|
| 1032 |
+
|
| 1033 |
+
## 17. v2 Roadmap — Insights from LinkedIn Thread
|
| 1034 |
+
|
| 1035 |
+
*Insights from practitioners who responded to Tom Blomfield's YC RFS post that should inform v2 product decisions.*
|
| 1036 |
+
|
| 1037 |
+
**Execution boundaries (Horizon Labs insight):** The skills file is currently advisory — the agent reads it and acts. In v2, the skills file should become constraining — the agent should not be able to take actions not in the admissible action set. This is the difference between a knowledge map and an execution boundary. Add to v2: `forbidden_actions` enforced at the runtime level, not just injected as prompt guidance.
|
| 1038 |
+
|
| 1039 |
+
**The stale knowledge divergence problem (Matan Elmalam insight):** Teams build the map once, ship the agent, and within six weeks reality diverges. Our stale detection addresses this for captured knowledge. For v2: active monitoring — compare agent actions against skills file weekly and surface divergences as "possible new skills" for human review.
|
| 1040 |
+
|
| 1041 |
+
**Call transcription (Paul Breuler gap):** Knowledge that exists only in spoken conversations will never be in Slack or Notion. In v2: integrate with Fireflies/Otter/Grain to pull meeting transcripts as a first-class source type. This closes the most common knowledge capture gap.
|
| 1042 |
+
|
| 1043 |
+
**Audit trail (Josh Jefferd insight):** Every agent action should be logged with which skill rule was applied and which evidence excerpt justified it. This is the compliance and trust layer. Add to v2 roadmap as a first-class feature, not an afterthought.
|
| 1044 |
+
|
| 1045 |
+
---
|
| 1046 |
+
|
| 1047 |
+
## 18. Open Questions — All Resolved
|
| 1048 |
+
|
| 1049 |
+
| Question | Resolution |
|
| 1050 |
+
|---|---|
|
| 1051 |
+
| Who owns frontend vs. pipeline? | Abhijith = pipeline + API. Harshit = all frontend. |
|
| 1052 |
+
| Supabase schema? | Defined in Section 11. |
|
| 1053 |
+
| SSE disconnect/reconnect handling? | Frontend: exponential backoff (1s, 2s, 4s). Fallback: `GET /brain/status` for final state. |
|
| 1054 |
+
| Synthetic dataset ownership? | Both — 4 files each, authored before May 4 kickoff. |
|
| 1055 |
+
| Ground truth table complete? | Yes — all 12 scenarios in Section 12. Run before demo. |
|
| 1056 |
+
| `with_brain: false` behaviour? | Fully specified in F-10 and Section 9. |
|
| 1057 |
+
| Screen-to-screen user flow? | Defined in Section 10.2. |
|
| 1058 |
+
|
| 1059 |
+
---
|
| 1060 |
+
|
| 1061 |
+
*This document supersedes company_brain_PRD_v3.md. All three audit issues resolved. Competitive landscape updated with real companies from LinkedIn thread. No scope changes after May 4 kickoff.*
|
data/sources/rivanly-inc/notion_cs_playbook.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Customer Success Playbook
|
| 2 |
+
**Department:** Customer Success
|
| 3 |
+
**Last Updated:** May 2026
|
| 4 |
+
|
| 5 |
+
## 1. Churn Risk Evaluation
|
| 6 |
+
It is critical to identify and intervene when accounts show signs of churning.
|
| 7 |
+
- **Rule:** If a customer exhibits 3 or more churn signals (e.g., no logins, support ticket escalations, downgrade inquiries) within a 30-day timeframe, you must schedule an AM call within 24 hours.
|
| 8 |
+
|
| 9 |
+
## 2. Enterprise Onboarding
|
| 10 |
+
- **Rule:** For all new Enterprise customers, the onboarding process must include a dedicated kickoff call, a customized training session, and a 30-day check-in.
|
data/sources/rivanly-inc/notion_eng_runbook.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Engineering Runbook & SLAs
|
| 2 |
+
**Department:** Product & Engineering
|
| 3 |
+
**Last Updated:** February 2026
|
| 4 |
+
|
| 5 |
+
## 1. Bug Triage
|
| 6 |
+
- **P0 (Critical):** System down, data loss, or core workflow broken.
|
| 7 |
+
- **Rule:** If a P0 bug is reported by an Enterprise customer, page the on-call engineer immediately.
|
| 8 |
+
- **P1 (High):** Major feature broken but workaround exists.
|
| 9 |
+
- **Rule:** P1 bugs must be resolved within 4 hours.
|
| 10 |
+
- **P2 (Medium/Low):** UI glitches, minor inconveniences. Add to the backlog.
|
| 11 |
+
|
| 12 |
+
## 2. SLA Breach Handling
|
| 13 |
+
- **Standard Process:** If a customer SLA is breached by more than 1 hour, notify the support lead.
|
| 14 |
+
- **Enterprise Exceptions:** If an Enterprise plan customer SLA is breached by 2 hours or more, you must notify both the Account Manager and the Engineering Lead immediately.
|
| 15 |
+
|
| 16 |
+
## 3. Outage Response
|
| 17 |
+
- If a customer contacts support during an active platform outage, do not troubleshoot. Send the standard incident response template and link to the status page.
|
data/sources/rivanly-inc/notion_hr_playbook.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HR & Hiring Playbook
|
| 2 |
+
**Department:** HR
|
| 3 |
+
**Last Updated:** January 2026
|
| 4 |
+
|
| 5 |
+
## 1. Engineering Hiring Process
|
| 6 |
+
The standard hiring process for Engineering roles:
|
| 7 |
+
1. Recruiter Screen
|
| 8 |
+
2. Technical Interview (Pair Programming)
|
| 9 |
+
3. Systems Design Interview
|
| 10 |
+
4. Culture Fit with Founders
|
| 11 |
+
5. Offer Stage
|
| 12 |
+
|
| 13 |
+
**Critical Rule:** For any engineering candidate at the offer stage, you must get Founder approval before sending the final offer letter.
|
| 14 |
+
|
| 15 |
+
## 2. Performance & PIPs
|
| 16 |
+
- A Performance Improvement Plan (PIP) is triggered if an employee misses their core KPIs for two consecutive quarters.
|
| 17 |
+
- If an employee is placed on a PIP, HR must schedule a formal review with the department head within 5 business days.
|
data/sources/rivanly-inc/notion_pricing_policy.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pricing & Discount Policy
|
| 2 |
+
**Department:** Revenue
|
| 3 |
+
**Last Updated:** April 2026
|
| 4 |
+
|
| 5 |
+
## 1. Overview
|
| 6 |
+
This document outlines standard pricing exceptions and discount approval chains.
|
| 7 |
+
|
| 8 |
+
## 2. Discount Authority
|
| 9 |
+
- **Standard Discount:** Support and CS can apply up to a 10% discount to save a churning customer.
|
| 10 |
+
- **Startup Discount:** If a customer identifies as an early-stage startup (pre-seed or seed), you may approve up to a 20% discount on the Annual plan for the first year.
|
| 11 |
+
- **Large Discounts:** If a customer requests a discount greater than 30%, it must be escalated to an Account Executive (AE) for approval. Support cannot approve this.
|
| 12 |
+
|
| 13 |
+
## 3. Custom Pricing
|
| 14 |
+
- **Enterprise Custom Pricing:** Enterprise customers requesting custom feature bundles or volume-based pricing must be routed to the VP of Sales.
|
data/sources/rivanly-inc/notion_refund_sop.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Refund Standard Operating Procedure (SOP)
|
| 2 |
+
**Department:** Support
|
| 3 |
+
**Last Updated:** March 2026
|
| 4 |
+
|
| 5 |
+
## 1. Core Policy
|
| 6 |
+
Our refund policy is designed to balance customer satisfaction with revenue retention. Always aim to understand the root cause before processing a refund.
|
| 7 |
+
|
| 8 |
+
## 2. Refund Eligibility & Rules
|
| 9 |
+
- **Annual Plans (First 14 days):** If a customer on an annual plan requests a refund within the first 14 days of purchase, approve a full refund immediately. No questions asked.
|
| 10 |
+
- **Annual Plans (After 14 days):** If a customer on an annual plan requests a refund after 14 days, approve a prorated refund for the remaining unused months.
|
| 11 |
+
- **Enterprise Customers:** If any Enterprise customer requests a refund of any amount, DO NOT process it immediately. You must escalate to the Account Manager (AM) within 1 hour.
|
| 12 |
+
- **Lifetime Deals (LTD):** Under no circumstances do we process refunds for lifetime deal accounts. Deny the request citing LTD terms.
|
| 13 |
+
- **Monthly Plans (New Customers):** If a customer on a monthly plan with a tenure of less than 3 months requests a refund over $500, escalate to the Founder.
|
| 14 |
+
|
| 15 |
+
## 3. Strict Time Limits
|
| 16 |
+
**CRITICAL:** We offer absolutely no refunds after 30 days of purchase for any customer tier. If the purchase was more than 30 days ago, deny the refund.
|
data/sources/rivanly-inc/slack_export_ops.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"channel": "finance-ops",
|
| 4 |
+
"user": "jessica_fin",
|
| 5 |
+
"text": "I have an incoming vendor invoice for $3,500 from Datadog (software vendor). Who needs to approve this?",
|
| 6 |
+
"timestamp": "2026-02-15T09:30:00Z"
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"channel": "finance-ops",
|
| 10 |
+
"user": "david_ops_lead",
|
| 11 |
+
"text": "Any software vendor invoice of $3,500 or more needs to be routed to the ops lead for approval before finance pays it. Send it my way.",
|
| 12 |
+
"timestamp": "2026-02-15T09:45:00Z"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"channel": "finance-ops",
|
| 16 |
+
"user": "jessica_fin",
|
| 17 |
+
"text": "Got it, routing it to you.",
|
| 18 |
+
"timestamp": "2026-02-15T09:46:00Z"
|
| 19 |
+
}
|
| 20 |
+
]
|
data/sources/rivanly-inc/slack_export_support.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"channel": "support-triage",
|
| 4 |
+
"user": "sarah_am",
|
| 5 |
+
"text": "Hey team, Acme Corp (been with us 4 years) is asking for a refund for last month's invoice due to the billing mixup. It's been 45 days since that charge, I know SOP says 30 days max.",
|
| 6 |
+
"timestamp": "2026-03-12T10:00:00Z"
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"channel": "support-triage",
|
| 10 |
+
"user": "mike_lead",
|
| 11 |
+
"text": "For loyal customers over 2 years tenure, we can bypass the 30-day rule. Go ahead and approve the refund for Acme Corp.",
|
| 12 |
+
"timestamp": "2026-03-12T10:05:00Z"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"channel": "support-triage",
|
| 16 |
+
"user": "alex_support",
|
| 17 |
+
"text": "We have an active platform outage affecting all EU servers. Customers are opening tickets left and right.",
|
| 18 |
+
"timestamp": "2026-04-01T14:20:00Z"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"channel": "support-triage",
|
| 22 |
+
"user": "mike_lead",
|
| 23 |
+
"text": "Do not try to troubleshoot individual EU tickets right now. Just send the incident response template and close the tickets.",
|
| 24 |
+
"timestamp": "2026-04-01T14:22:00Z"
|
| 25 |
+
}
|
| 26 |
+
]
|
data/sources/rivanly-inc/zendesk_tickets.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "TICKET-1042",
|
| 4 |
+
"subject": "Dashboard not loading",
|
| 5 |
+
"description": "I cannot access the main dashboard. It just spins forever.",
|
| 6 |
+
"resolution": "P0 bug confirmed. Paged on-call engineer immediately as this is an Enterprise customer.",
|
| 7 |
+
"tags": ["bug", "P0", "enterprise"]
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"id": "TICKET-1045",
|
| 11 |
+
"subject": "Export feature failing",
|
| 12 |
+
"description": "When I try to export reports to CSV, it fails.",
|
| 13 |
+
"resolution": "Confirmed P1 bug for Enterprise customer GlobalCorp. Escalated to Eng and resolved same-day (within 12 hours), outside the normal 4-hour SLA but acceptable for this specific complex issue for Enterprise.",
|
| 14 |
+
"tags": ["bug", "P1", "enterprise"]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "TICKET-1088",
|
| 18 |
+
"subject": "SLA breached on ticket 1087",
|
| 19 |
+
"description": "We have been waiting 6 hours for a response.",
|
| 20 |
+
"resolution": "SLA breached by 2 hours for Enterprise customer. Notified AM and Eng Lead immediately.",
|
| 21 |
+
"tags": ["sla_breach", "enterprise"]
|
| 22 |
+
}
|
| 23 |
+
]
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
frontend/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"next": "16.2.5",
|
| 13 |
+
"react": "19.2.4",
|
| 14 |
+
"react-dom": "19.2.4"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@tailwindcss/postcss": "^4",
|
| 18 |
+
"@types/node": "^20",
|
| 19 |
+
"@types/react": "^19",
|
| 20 |
+
"@types/react-dom": "^19",
|
| 21 |
+
"eslint": "^9",
|
| 22 |
+
"eslint-config-next": "16.2.5",
|
| 23 |
+
"tailwindcss": "^4",
|
| 24 |
+
"typescript": "^5"
|
| 25 |
+
}
|
| 26 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/public/file.svg
ADDED
|
|
frontend/public/globe.svg
ADDED
|
|
frontend/public/next.svg
ADDED
|
|
frontend/public/vercel.svg
ADDED
|
|
frontend/public/window.svg
ADDED
|
|
frontend/src/app/compile/[jobId]/page.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, use } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
interface LogEvent {
|
| 7 |
+
timestamp: string;
|
| 8 |
+
type: string;
|
| 9 |
+
data: any;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const STAGE_LABELS: Record<string, string> = {
|
| 13 |
+
pipeline_start: "🚀 Pipeline Started",
|
| 14 |
+
LOADING_DOCS: "📂 Loading Documents",
|
| 15 |
+
CHUNKING: "✂️ Chunking Documents",
|
| 16 |
+
CHUNKING_DONE: "✅ Chunking Complete",
|
| 17 |
+
EMBEDDING: "🧠 Embedding & Clustering",
|
| 18 |
+
EMBEDDING_DONE: "✅ Clustering Complete",
|
| 19 |
+
SYNTHESIZING_SKILLS: "⚡ Synthesizing Skills",
|
| 20 |
+
QUALITY_CHECK: "🔍 Quality & Confidence Scoring",
|
| 21 |
+
QUALITY_CHECK_DONE: "✅ Quality Check Complete",
|
| 22 |
+
WRITING_DB: "💾 Writing to Database",
|
| 23 |
+
DONE: "✅ Pipeline Complete",
|
| 24 |
+
pipeline_complete: "🎉 Compilation Finished",
|
| 25 |
+
pipeline_error: "❌ Pipeline Error",
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export default function CompileViewer({ params }: { params: Promise<{ jobId: string }> }) {
|
| 29 |
+
const resolvedParams = use(params);
|
| 30 |
+
const jobId = resolvedParams.jobId;
|
| 31 |
+
const [logs, setLogs] = useState<LogEvent[]>([]);
|
| 32 |
+
const [status, setStatus] = useState("Connecting...");
|
| 33 |
+
const router = useRouter();
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
if (!jobId) return;
|
| 37 |
+
|
| 38 |
+
const eventSource = new EventSource(`http://localhost:8080/compile/${jobId}/stream`);
|
| 39 |
+
|
| 40 |
+
eventSource.onmessage = (event) => {
|
| 41 |
+
const parsed = JSON.parse(event.data);
|
| 42 |
+
const eventType = parsed.event;
|
| 43 |
+
const eventData = parsed.data;
|
| 44 |
+
|
| 45 |
+
setLogs((prev) => [
|
| 46 |
+
...prev,
|
| 47 |
+
{ timestamp: new Date().toLocaleTimeString(), type: eventType, data: eventData },
|
| 48 |
+
]);
|
| 49 |
+
|
| 50 |
+
// Update the status bar based on event type
|
| 51 |
+
if (eventType === "stage") {
|
| 52 |
+
const stageName = eventData.name || "";
|
| 53 |
+
const label = STAGE_LABELS[stageName] || stageName;
|
| 54 |
+
const detail = eventData.detail || "";
|
| 55 |
+
setStatus(`${label}${detail ? ` — ${detail}` : ""}`);
|
| 56 |
+
} else if (eventType === "pipeline_start") {
|
| 57 |
+
setStatus(STAGE_LABELS.pipeline_start);
|
| 58 |
+
} else if (eventType === "pipeline_complete") {
|
| 59 |
+
setStatus(STAGE_LABELS.pipeline_complete);
|
| 60 |
+
eventSource.close();
|
| 61 |
+
} else if (eventType === "pipeline_error") {
|
| 62 |
+
setStatus(`❌ Error: ${eventData.error || "Unknown"}`);
|
| 63 |
+
eventSource.close();
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
eventSource.onerror = () => {
|
| 68 |
+
eventSource.close();
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
return () => eventSource.close();
|
| 72 |
+
}, [jobId]);
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="min-h-screen p-8 flex flex-col">
|
| 76 |
+
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
|
| 77 |
+
<h1 className="text-2xl font-bold text-primary">Pipeline Stream</h1>
|
| 78 |
+
<div className="flex items-center gap-4">
|
| 79 |
+
<span
|
| 80 |
+
className={`px-3 py-1 font-mono text-sm border ${
|
| 81 |
+
status.includes("Finished") || status.includes("Complete")
|
| 82 |
+
? "border-green-500 text-green-500"
|
| 83 |
+
: status.includes("Error")
|
| 84 |
+
? "border-red-500 text-red-500"
|
| 85 |
+
: "border-primary text-primary animate-pulse"
|
| 86 |
+
}`}
|
| 87 |
+
>
|
| 88 |
+
{status}
|
| 89 |
+
</span>
|
| 90 |
+
<button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
|
| 91 |
+
Back
|
| 92 |
+
</button>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className="flex-1 bg-surface border border-gray-800 p-4 font-mono text-sm overflow-y-auto">
|
| 97 |
+
{logs.map((log, i) => {
|
| 98 |
+
const isStage = log.type === "stage";
|
| 99 |
+
const stageName = isStage ? log.data?.name : log.type;
|
| 100 |
+
const label = STAGE_LABELS[stageName] || stageName;
|
| 101 |
+
const detail = isStage ? log.data?.detail || "" : JSON.stringify(log.data);
|
| 102 |
+
const isError = stageName?.includes("error") || stageName?.includes("Error");
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
<div key={i} className="mb-2">
|
| 106 |
+
<span className="text-text-secondary">[{log.timestamp}]</span>{" "}
|
| 107 |
+
<span className={isError ? "text-red-500" : "text-primary"}>{label}</span>{" "}
|
| 108 |
+
<span className="text-foreground">{detail}</span>
|
| 109 |
+
</div>
|
| 110 |
+
);
|
| 111 |
+
})}
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
);
|
| 115 |
+
}
|
frontend/src/app/demo/[companyId]/page.tsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, use } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
type AgentResponse = {
|
| 7 |
+
recommended_action?: string;
|
| 8 |
+
rule_applied?: string;
|
| 9 |
+
evidence?: string[];
|
| 10 |
+
skill_matched?: string;
|
| 11 |
+
confidence?: number;
|
| 12 |
+
retrieval_scores?: number[];
|
| 13 |
+
reasoning?: string;
|
| 14 |
+
error?: string;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default function QueryDemo({ params }: { params: Promise<{ companyId: string }> }) {
|
| 18 |
+
const resolvedParams = use(params);
|
| 19 |
+
const companyId = resolvedParams.companyId;
|
| 20 |
+
const [scenario, setScenario] = useState("");
|
| 21 |
+
const [contextJson, setContextJson] = useState("{}");
|
| 22 |
+
const [loading, setLoading] = useState(false);
|
| 23 |
+
|
| 24 |
+
const [withBrainResponse, setWithBrainResponse] = useState<AgentResponse | null>(null);
|
| 25 |
+
const [withoutBrainResponse, setWithoutBrainResponse] = useState<AgentResponse | null>(null);
|
| 26 |
+
|
| 27 |
+
const router = useRouter();
|
| 28 |
+
|
| 29 |
+
const handleQuery = async (e: React.FormEvent) => {
|
| 30 |
+
e.preventDefault();
|
| 31 |
+
if (!scenario) return;
|
| 32 |
+
setLoading(true);
|
| 33 |
+
setWithBrainResponse(null);
|
| 34 |
+
setWithoutBrainResponse(null);
|
| 35 |
+
|
| 36 |
+
let parsedContext = {};
|
| 37 |
+
try {
|
| 38 |
+
if (contextJson.trim()) {
|
| 39 |
+
parsedContext = JSON.parse(contextJson);
|
| 40 |
+
}
|
| 41 |
+
} catch {
|
| 42 |
+
alert("Invalid JSON in context field");
|
| 43 |
+
setLoading(false);
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
try {
|
| 48 |
+
const [resWithBrain, resWithoutBrain] = await Promise.all([
|
| 49 |
+
fetch("http://localhost:8080/agent/handle", {
|
| 50 |
+
method: "POST",
|
| 51 |
+
headers: { "Content-Type": "application/json" },
|
| 52 |
+
body: JSON.stringify({ company_id: companyId, scenario, context: parsedContext, with_brain: true }),
|
| 53 |
+
}),
|
| 54 |
+
fetch("http://localhost:8080/agent/handle", {
|
| 55 |
+
method: "POST",
|
| 56 |
+
headers: { "Content-Type": "application/json" },
|
| 57 |
+
body: JSON.stringify({ company_id: companyId, scenario, context: parsedContext, with_brain: false }),
|
| 58 |
+
}),
|
| 59 |
+
]);
|
| 60 |
+
|
| 61 |
+
setWithBrainResponse(await resWithBrain.json());
|
| 62 |
+
setWithoutBrainResponse(await resWithoutBrain.json());
|
| 63 |
+
} catch (err) {
|
| 64 |
+
console.error(err);
|
| 65 |
+
alert("Query failed — is the backend running?");
|
| 66 |
+
} finally {
|
| 67 |
+
setLoading(false);
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const confidenceColor = (c: number) => {
|
| 72 |
+
if (c >= 0.75) return "bg-green-500";
|
| 73 |
+
if (c >= 0.5) return "bg-yellow-500";
|
| 74 |
+
if (c >= 0.25) return "bg-orange-500";
|
| 75 |
+
return "bg-red-500";
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<div className="min-h-screen p-8 flex flex-col items-center">
|
| 80 |
+
<div className="w-full max-w-5xl">
|
| 81 |
+
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
|
| 82 |
+
<h1 className="text-2xl font-bold text-primary">Brain Query Demo</h1>
|
| 83 |
+
<button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
|
| 84 |
+
Back to Dashboard
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<form onSubmit={handleQuery} className="mb-8 bg-surface p-6 border border-gray-800">
|
| 89 |
+
<div className="flex flex-col gap-4">
|
| 90 |
+
<div>
|
| 91 |
+
<label className="block text-text-secondary text-sm font-bold mb-2">Scenario</label>
|
| 92 |
+
<textarea
|
| 93 |
+
className="w-full px-4 py-3 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary min-h-[100px]"
|
| 94 |
+
placeholder="Enterprise customer, 18 months tenure, wants $1,200 refund"
|
| 95 |
+
value={scenario}
|
| 96 |
+
onChange={(e) => setScenario(e.target.value)}
|
| 97 |
+
/>
|
| 98 |
+
</div>
|
| 99 |
+
<div>
|
| 100 |
+
<label className="block text-text-secondary text-sm font-bold mb-2">Context (JSON)</label>
|
| 101 |
+
<textarea
|
| 102 |
+
className="w-full px-4 py-3 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary font-mono text-sm min-h-[80px]"
|
| 103 |
+
placeholder='{"plan": "enterprise", "tenure_months": 18, "refund_amount": 1200}'
|
| 104 |
+
value={contextJson}
|
| 105 |
+
onChange={(e) => setContextJson(e.target.value)}
|
| 106 |
+
/>
|
| 107 |
+
</div>
|
| 108 |
+
<button
|
| 109 |
+
type="submit"
|
| 110 |
+
disabled={loading || !scenario}
|
| 111 |
+
className="bg-primary text-background font-bold py-3 px-6 hover:opacity-90 disabled:opacity-50 self-end"
|
| 112 |
+
>
|
| 113 |
+
{loading ? "Thinking..." : "Compare Models"}
|
| 114 |
+
</button>
|
| 115 |
+
</div>
|
| 116 |
+
</form>
|
| 117 |
+
|
| 118 |
+
{(withBrainResponse || withoutBrainResponse) && (
|
| 119 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 120 |
+
{/* WITHOUT BRAIN */}
|
| 121 |
+
<div className="bg-surface border border-gray-800 p-6 opacity-75">
|
| 122 |
+
<h2 className="text-xl font-bold text-gray-400 mb-4 flex items-center gap-2">
|
| 123 |
+
<span className="w-2 h-2 rounded-full bg-gray-500"></span>
|
| 124 |
+
Without Brain (Generic AI)
|
| 125 |
+
</h2>
|
| 126 |
+
|
| 127 |
+
{withoutBrainResponse ? (
|
| 128 |
+
<div className="space-y-4 text-gray-300">
|
| 129 |
+
<div>
|
| 130 |
+
<h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Response</h3>
|
| 131 |
+
<p className="text-lg bg-background p-4 border border-gray-800 rounded">
|
| 132 |
+
{withoutBrainResponse.recommended_action || "No action"}
|
| 133 |
+
</p>
|
| 134 |
+
</div>
|
| 135 |
+
<div>
|
| 136 |
+
<h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Rule Applied</h3>
|
| 137 |
+
<p className="italic">{withoutBrainResponse.rule_applied || "General knowledge"}</p>
|
| 138 |
+
</div>
|
| 139 |
+
{withoutBrainResponse.reasoning && (
|
| 140 |
+
<div>
|
| 141 |
+
<h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Reasoning</h3>
|
| 142 |
+
<p className="text-sm">{withoutBrainResponse.reasoning}</p>
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
) : (
|
| 147 |
+
<p>Loading...</p>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* WITH BRAIN */}
|
| 152 |
+
<div className="bg-surface border-2 border-primary p-6 relative shadow-[0_0_15px_rgba(45,212,191,0.1)]">
|
| 153 |
+
<div className="absolute -top-3 -right-3 bg-primary text-background text-xs font-bold px-3 py-1 uppercase tracking-wider rounded-full">
|
| 154 |
+
Company Brain
|
| 155 |
+
</div>
|
| 156 |
+
<h2 className="text-xl font-bold text-primary mb-4 flex items-center gap-2">
|
| 157 |
+
<span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
|
| 158 |
+
With Brain (Compiled Agent)
|
| 159 |
+
</h2>
|
| 160 |
+
|
| 161 |
+
{withBrainResponse ? (
|
| 162 |
+
<div className="space-y-4">
|
| 163 |
+
{withBrainResponse.error ? (
|
| 164 |
+
<p className="text-red-400">{withBrainResponse.error}</p>
|
| 165 |
+
) : (
|
| 166 |
+
<>
|
| 167 |
+
<div>
|
| 168 |
+
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 169 |
+
Recommended Action
|
| 170 |
+
</h3>
|
| 171 |
+
<p className="text-xl font-semibold text-white bg-primary/10 p-4 border border-primary/30 rounded">
|
| 172 |
+
{withBrainResponse.recommended_action}
|
| 173 |
+
</p>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div className="grid grid-cols-2 gap-4">
|
| 177 |
+
<div>
|
| 178 |
+
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 179 |
+
Skill Matched
|
| 180 |
+
</h3>
|
| 181 |
+
<p className="font-mono text-sm bg-background p-2 rounded">
|
| 182 |
+
{withBrainResponse.skill_matched || "N/A"}
|
| 183 |
+
</p>
|
| 184 |
+
</div>
|
| 185 |
+
<div>
|
| 186 |
+
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 187 |
+
Confidence
|
| 188 |
+
</h3>
|
| 189 |
+
<div className="flex items-center gap-2 mt-2">
|
| 190 |
+
<div className="flex-1 bg-background h-2 rounded-full overflow-hidden">
|
| 191 |
+
<div
|
| 192 |
+
className={`h-full ${confidenceColor(withBrainResponse.confidence || 0)}`}
|
| 193 |
+
style={{ width: `${(withBrainResponse.confidence || 0) * 100}%` }}
|
| 194 |
+
></div>
|
| 195 |
+
</div>
|
| 196 |
+
<span className="text-xs font-mono">
|
| 197 |
+
{((withBrainResponse.confidence || 0) * 100).toFixed(0)}%
|
| 198 |
+
</span>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Retrieval Scores */}
|
| 204 |
+
{withBrainResponse.retrieval_scores && withBrainResponse.retrieval_scores.length > 0 && (
|
| 205 |
+
<div>
|
| 206 |
+
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 207 |
+
Retrieval Scores (Top {withBrainResponse.retrieval_scores.length} Skills)
|
| 208 |
+
</h3>
|
| 209 |
+
<div className="flex gap-2 flex-wrap">
|
| 210 |
+
{withBrainResponse.retrieval_scores.map((score, i) => (
|
| 211 |
+
<span
|
| 212 |
+
key={i}
|
| 213 |
+
className="bg-background border border-gray-700 px-2 py-1 rounded text-xs font-mono"
|
| 214 |
+
>
|
| 215 |
+
#{i + 1}: {(score * 100).toFixed(1)}%
|
| 216 |
+
</span>
|
| 217 |
+
))}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
|
| 222 |
+
<div>
|
| 223 |
+
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 224 |
+
Rule Applied
|
| 225 |
+
</h3>
|
| 226 |
+
<p className="text-white border-l-2 border-primary pl-3 py-1 font-medium">
|
| 227 |
+
{withBrainResponse.rule_applied}
|
| 228 |
+
</p>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
{/* Reasoning */}
|
| 232 |
+
{withBrainResponse.reasoning && (
|
| 233 |
+
<div>
|
| 234 |
+
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
|
| 235 |
+
LLM Reasoning
|
| 236 |
+
</h3>
|
| 237 |
+
<p className="text-sm text-gray-300 bg-background p-3 rounded border border-gray-800">
|
| 238 |
+
{withBrainResponse.reasoning}
|
| 239 |
+
</p>
|
| 240 |
+
</div>
|
| 241 |
+
)}
|
| 242 |
+
|
| 243 |
+
{withBrainResponse.evidence && withBrainResponse.evidence.length > 0 && (
|
| 244 |
+
<div>
|
| 245 |
+
<h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-2">
|
| 246 |
+
Evidence Trail
|
| 247 |
+
</h3>
|
| 248 |
+
<ul className="space-y-2">
|
| 249 |
+
{withBrainResponse.evidence.map((src, i) => (
|
| 250 |
+
<li key={i} className="text-gray-300 text-sm bg-background p-3 rounded border border-gray-800">
|
| 251 |
+
{src}
|
| 252 |
+
</li>
|
| 253 |
+
))}
|
| 254 |
+
</ul>
|
| 255 |
+
</div>
|
| 256 |
+
)}
|
| 257 |
+
</>
|
| 258 |
+
)}
|
| 259 |
+
</div>
|
| 260 |
+
) : (
|
| 261 |
+
<p>Loading...</p>
|
| 262 |
+
)}
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
);
|
| 269 |
+
}
|
frontend/src/app/favicon.ico
ADDED
|
|
frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #0A0F14;
|
| 5 |
+
--foreground: #E2E8F0;
|
| 6 |
+
--surface: #131B23;
|
| 7 |
+
--primary: #00D2B4;
|
| 8 |
+
--text-secondary: #94A3B8;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
@theme inline {
|
| 12 |
+
--color-background: var(--background);
|
| 13 |
+
--color-foreground: var(--foreground);
|
| 14 |
+
--color-surface: var(--surface);
|
| 15 |
+
--color-primary: var(--primary);
|
| 16 |
+
--color-text-secondary: var(--text-secondary);
|
| 17 |
+
--font-sans: var(--font-geist-sans);
|
| 18 |
+
--font-mono: var(--font-geist-mono);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
body {
|
| 22 |
+
background: var(--background);
|
| 23 |
+
color: var(--foreground);
|
| 24 |
+
}
|
frontend/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const geistSans = Geist({
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const geistMono = Geist_Mono({
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "Create Next App",
|
| 17 |
+
description: "Generated by create next app",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html
|
| 27 |
+
lang="en"
|
| 28 |
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 29 |
+
>
|
| 30 |
+
<body className="min-h-full flex flex-col">{children}</body>
|
| 31 |
+
</html>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/src/app/page.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
export default function Dashboard() {
|
| 7 |
+
const [companyId, setCompanyId] = useState("");
|
| 8 |
+
const [loading, setLoading] = useState(false);
|
| 9 |
+
const router = useRouter();
|
| 10 |
+
|
| 11 |
+
const handleCompile = async () => {
|
| 12 |
+
if (!companyId) return;
|
| 13 |
+
setLoading(true);
|
| 14 |
+
try {
|
| 15 |
+
const res = await fetch("http://localhost:8080/compile", {
|
| 16 |
+
method: "POST",
|
| 17 |
+
headers: { "Content-Type": "application/json" },
|
| 18 |
+
body: JSON.stringify({ company_id: companyId }),
|
| 19 |
+
});
|
| 20 |
+
const data = await res.json();
|
| 21 |
+
if (data.job_id) {
|
| 22 |
+
router.push(`/compile/${data.job_id}`);
|
| 23 |
+
}
|
| 24 |
+
} catch (err) {
|
| 25 |
+
console.error(err);
|
| 26 |
+
alert("Failed to start compilation");
|
| 27 |
+
} finally {
|
| 28 |
+
setLoading(false);
|
| 29 |
+
}
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const handleQuery = () => {
|
| 33 |
+
if (companyId) {
|
| 34 |
+
router.push(`/demo/${companyId}`);
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const handleViewSkills = () => {
|
| 39 |
+
if (companyId) {
|
| 40 |
+
router.push(`/skills/${companyId}`);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="min-h-screen p-8 flex flex-col items-center justify-center">
|
| 46 |
+
<div className="max-w-md w-full bg-surface p-8 border border-gray-800 shadow-2xl">
|
| 47 |
+
<h1 className="text-3xl font-bold text-primary mb-6">Kernl Compilation</h1>
|
| 48 |
+
|
| 49 |
+
<div className="mb-6">
|
| 50 |
+
<label className="block text-text-secondary text-sm font-bold mb-2">
|
| 51 |
+
Company ID
|
| 52 |
+
</label>
|
| 53 |
+
<input
|
| 54 |
+
type="text"
|
| 55 |
+
className="w-full px-3 py-2 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary"
|
| 56 |
+
placeholder="e.g. comp_123"
|
| 57 |
+
value={companyId}
|
| 58 |
+
onChange={(e) => setCompanyId(e.target.value)}
|
| 59 |
+
/>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div className="flex flex-col gap-3">
|
| 63 |
+
<button
|
| 64 |
+
onClick={handleCompile}
|
| 65 |
+
disabled={loading || !companyId}
|
| 66 |
+
className="w-full bg-primary text-background font-bold py-2 px-4 hover:opacity-90 disabled:opacity-50"
|
| 67 |
+
>
|
| 68 |
+
{loading ? "Starting..." : "Compile Brain"}
|
| 69 |
+
</button>
|
| 70 |
+
|
| 71 |
+
<button
|
| 72 |
+
onClick={handleViewSkills}
|
| 73 |
+
disabled={!companyId}
|
| 74 |
+
className="w-full border border-primary text-primary font-bold py-2 px-4 hover:bg-primary/10 disabled:opacity-50"
|
| 75 |
+
>
|
| 76 |
+
View Skills File
|
| 77 |
+
</button>
|
| 78 |
+
|
| 79 |
+
<button
|
| 80 |
+
onClick={handleQuery}
|
| 81 |
+
disabled={!companyId}
|
| 82 |
+
className="w-full border border-gray-600 text-foreground font-bold py-2 px-4 hover:bg-gray-800 disabled:opacity-50"
|
| 83 |
+
>
|
| 84 |
+
Query Agent Demo
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
frontend/src/app/skills/[companyId]/page.tsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, use } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
type Skill = {
|
| 7 |
+
id?: string;
|
| 8 |
+
category?: string;
|
| 9 |
+
rule?: string;
|
| 10 |
+
rationale?: string;
|
| 11 |
+
evidence?: string[];
|
| 12 |
+
confidence?: number;
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
type SkillsData = {
|
| 16 |
+
skills: Skill[];
|
| 17 |
+
version?: string;
|
| 18 |
+
compiled_at?: string;
|
| 19 |
+
brain_id?: string;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export default function SkillsViewer({ params }: { params: Promise<{ companyId: string }> }) {
|
| 23 |
+
const resolvedParams = use(params);
|
| 24 |
+
const companyId = resolvedParams.companyId;
|
| 25 |
+
const [data, setData] = useState<SkillsData | null>(null);
|
| 26 |
+
const [loading, setLoading] = useState(true);
|
| 27 |
+
const [filter, setFilter] = useState("");
|
| 28 |
+
const [sortBy, setSortBy] = useState<"category" | "confidence">("category");
|
| 29 |
+
const router = useRouter();
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
fetch(`http://localhost:8080/skills/${companyId}`)
|
| 33 |
+
.then((res) => res.json())
|
| 34 |
+
.then((d) => {
|
| 35 |
+
setData(d);
|
| 36 |
+
setLoading(false);
|
| 37 |
+
})
|
| 38 |
+
.catch((err) => {
|
| 39 |
+
console.error(err);
|
| 40 |
+
setLoading(false);
|
| 41 |
+
});
|
| 42 |
+
}, [companyId]);
|
| 43 |
+
|
| 44 |
+
const skills = data?.skills || [];
|
| 45 |
+
const categories = [...new Set(skills.map((s) => s.category || "Unknown"))];
|
| 46 |
+
|
| 47 |
+
const filtered = skills
|
| 48 |
+
.filter((s) => {
|
| 49 |
+
if (!filter) return true;
|
| 50 |
+
return (s.category || "").toLowerCase().includes(filter.toLowerCase());
|
| 51 |
+
})
|
| 52 |
+
.sort((a, b) => {
|
| 53 |
+
if (sortBy === "confidence") return (b.confidence || 0) - (a.confidence || 0);
|
| 54 |
+
return (a.category || "").localeCompare(b.category || "");
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
const confidenceColor = (c: number) => {
|
| 58 |
+
if (c >= 0.8) return "text-green-400 border-green-400/30";
|
| 59 |
+
if (c >= 0.6) return "text-yellow-400 border-yellow-400/30";
|
| 60 |
+
if (c >= 0.4) return "text-orange-400 border-orange-400/30";
|
| 61 |
+
return "text-red-400 border-red-400/30";
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="min-h-screen p-8 flex flex-col">
|
| 66 |
+
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
|
| 67 |
+
<div>
|
| 68 |
+
<h1 className="text-2xl font-bold text-primary">Skills File Viewer</h1>
|
| 69 |
+
{data?.version && (
|
| 70 |
+
<p className="text-text-secondary text-sm mt-1">
|
| 71 |
+
Version: <span className="font-mono text-primary">{data.version}</span>
|
| 72 |
+
{data.compiled_at && (
|
| 73 |
+
<> · Compiled: {new Date(data.compiled_at).toLocaleString()}</>
|
| 74 |
+
)}
|
| 75 |
+
{" · "}{skills.length} skills
|
| 76 |
+
</p>
|
| 77 |
+
)}
|
| 78 |
+
</div>
|
| 79 |
+
<button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
|
| 80 |
+
Back
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Filter + Sort Controls */}
|
| 85 |
+
<div className="flex gap-4 mb-4">
|
| 86 |
+
<select
|
| 87 |
+
value={filter}
|
| 88 |
+
onChange={(e) => setFilter(e.target.value)}
|
| 89 |
+
className="bg-surface border border-gray-700 text-foreground px-3 py-2 text-sm"
|
| 90 |
+
>
|
| 91 |
+
<option value="">All Categories</option>
|
| 92 |
+
{categories.map((c) => (
|
| 93 |
+
<option key={c} value={c}>{c}</option>
|
| 94 |
+
))}
|
| 95 |
+
</select>
|
| 96 |
+
<select
|
| 97 |
+
value={sortBy}
|
| 98 |
+
onChange={(e) => setSortBy(e.target.value as "category" | "confidence")}
|
| 99 |
+
className="bg-surface border border-gray-700 text-foreground px-3 py-2 text-sm"
|
| 100 |
+
>
|
| 101 |
+
<option value="category">Sort by Category</option>
|
| 102 |
+
<option value="confidence">Sort by Confidence</option>
|
| 103 |
+
</select>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Skills Grid */}
|
| 107 |
+
<div className="flex-1 overflow-y-auto">
|
| 108 |
+
{loading ? (
|
| 109 |
+
<div className="text-text-secondary">Loading skills...</div>
|
| 110 |
+
) : filtered.length === 0 ? (
|
| 111 |
+
<div className="text-center py-12">
|
| 112 |
+
<p className="text-text-secondary text-lg">No skills compiled yet.</p>
|
| 113 |
+
<p className="text-text-secondary text-sm mt-2">
|
| 114 |
+
Go to Dashboard → Compile Brain to generate skills from your source documents.
|
| 115 |
+
</p>
|
| 116 |
+
</div>
|
| 117 |
+
) : (
|
| 118 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
| 119 |
+
{filtered.map((skill, i) => (
|
| 120 |
+
<div
|
| 121 |
+
key={skill.id || i}
|
| 122 |
+
className="bg-surface border border-gray-800 p-5 hover:border-primary/30 transition-colors"
|
| 123 |
+
>
|
| 124 |
+
<div className="flex justify-between items-start mb-3">
|
| 125 |
+
<span className="text-xs font-mono bg-primary/10 text-primary px-2 py-1 rounded">
|
| 126 |
+
{skill.category || "Unknown"}
|
| 127 |
+
</span>
|
| 128 |
+
<span
|
| 129 |
+
className={`text-xs font-mono px-2 py-1 border rounded ${confidenceColor(
|
| 130 |
+
skill.confidence || 0
|
| 131 |
+
)}`}
|
| 132 |
+
>
|
| 133 |
+
{((skill.confidence || 0) * 100).toFixed(0)}%
|
| 134 |
+
</span>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<p className="text-white font-medium mb-2">{skill.rule}</p>
|
| 138 |
+
|
| 139 |
+
{skill.rationale && (
|
| 140 |
+
<p className="text-text-secondary text-sm mb-3 italic">{skill.rationale}</p>
|
| 141 |
+
)}
|
| 142 |
+
|
| 143 |
+
{skill.evidence && skill.evidence.length > 0 && (
|
| 144 |
+
<div className="border-t border-gray-800 pt-3 mt-3">
|
| 145 |
+
<h4 className="text-xs text-text-secondary uppercase tracking-wider mb-2">
|
| 146 |
+
Evidence ({skill.evidence.length})
|
| 147 |
+
</h4>
|
| 148 |
+
{skill.evidence.map((e, j) => (
|
| 149 |
+
<p key={j} className="text-xs text-gray-400 mb-1 pl-2 border-l border-gray-700">
|
| 150 |
+
{e}
|
| 151 |
+
</p>
|
| 152 |
+
))}
|
| 153 |
+
</div>
|
| 154 |
+
)}
|
| 155 |
+
</div>
|
| 156 |
+
))}
|
| 157 |
+
</div>
|
| 158 |
+
)}
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
}
|