-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
314 lines (271 loc) · 12.4 KB
/
Copy pathapp.py
File metadata and controls
314 lines (271 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"""vouch-ui — a self-hosted console over a vouch knowledge base.
One FastAPI process, one static page. The browser talks only to this app;
this app talks only to `vouch serve --transport http` (POST /rpc) and, when
a key is configured, to one OpenAI-compatible chat endpoint.
The review gate stays exactly where vouch put it: this app can search, read,
and *propose* — the LLM is never given an approve tool, and no route here
calls kb.approve / kb.reject. Reviewing stays in `vouch review-ui` / the CLI.
Config (env, or a .env file loaded by run.sh):
VOUCH_RPC_URL default http://127.0.0.1:8731/rpc
VOUCH_HTTP_TOKEN bearer token if the vouch server requires one
LLM_API_KEY enables chat; without it the console is read-only
LLM_BASE_URL default https://api.openai.com/v1 (any OpenAI-compatible)
LLM_MODEL default gpt-5.4
UI_HOST/UI_PORT default 127.0.0.1:8900
"""
from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
VOUCH_RPC_URL = os.environ.get("VOUCH_RPC_URL", "http://127.0.0.1:8731/rpc")
VOUCH_HTTP_TOKEN = os.environ.get("VOUCH_HTTP_TOKEN", "")
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1").rstrip("/")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-5.4")
STATIC_DIR = Path(__file__).parent / "static"
MAX_TOOL_ROUNDS = 6
MAX_HISTORY_MESSAGES = 24
app = FastAPI(title="vouch-ui")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# in-memory chat sessions; the durable record lives in vouch, not here
_sessions: dict[str, list[dict[str, Any]]] = {}
class RpcError(Exception):
pass
async def rpc(method: str, params: dict[str, Any] | None = None) -> Any:
headers = {"Content-Type": "application/json", "X-Vouch-Agent": "vouch-ui"}
if VOUCH_HTTP_TOKEN:
headers["Authorization"] = f"Bearer {VOUCH_HTTP_TOKEN}"
envelope = {"id": str(uuid.uuid4()), "method": method, "params": params or {}}
async with httpx.AsyncClient(timeout=30) as client:
try:
resp = await client.post(VOUCH_RPC_URL, json=envelope, headers=headers)
except httpx.HTTPError as e:
raise RpcError(f"vouch server unreachable at {VOUCH_RPC_URL}: {e}") from e
if resp.status_code != 200:
raise RpcError(f"vouch /rpc returned HTTP {resp.status_code}")
body = resp.json()
if not body.get("ok"):
err = body.get("error") or {}
raise RpcError(err.get("message") or "vouch rpc error")
return body.get("result")
# --- read routes (work with no LLM key) ------------------------------------
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/api/overview")
async def overview() -> dict[str, Any]:
try:
status = await rpc("kb.status")
digest = await rpc("kb.digest", {"since": "7d", "limit": 8})
except RpcError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
return {"status": status, "digest": digest, "llm_configured": bool(LLM_API_KEY),
"llm_model": LLM_MODEL if LLM_API_KEY else None}
@app.get("/api/pending")
async def pending() -> Any:
try:
return await rpc("kb.list_pending")
except RpcError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
@app.get("/api/search")
async def search(q: str, limit: int = 12) -> Any:
try:
return await rpc("kb.search", {"query": q, "limit": limit})
except RpcError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
@app.get("/api/record/{record_id}")
async def record(record_id: str) -> dict[str, Any]:
"""Resolve an id against claims, pages, entities, then sources."""
for kind, method, key in (
("claim", "kb.read_claim", "claim_id"),
("page", "kb.read_page", "page_id"),
("entity", "kb.read_entity", "entity_id"),
):
try:
body = await rpc(method, {key: record_id})
result: dict[str, Any] = {"kind": kind, "record": body}
if kind == "claim" and body.get("evidence"):
sources = await rpc("kb.list_sources")
wanted = set(body["evidence"])
result["evidence_sources"] = [
s for s in sources if s.get("id") in wanted
]
return result
except RpcError:
continue
try:
sources = await rpc("kb.list_sources")
except RpcError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
for s in sources:
if s.get("id") == record_id:
return {"kind": "source", "record": s}
raise HTTPException(status_code=404, detail=f"no record with id {record_id!r}")
# --- chat (needs LLM_API_KEY) ----------------------------------------------
SYSTEM_PROMPT = """You are the console over a review-gated team knowledge base.
Rules:
- Answer only from the KB via your tools. Cite every claim, page, or source
you rely on as [its-id] inline. If the KB cannot support an answer, say so
and show the closest results — never substitute general knowledge silently.
- "What needs attention" -> kb_digest.
- When asked to remember or record something: kb_register_source with the
user's words, then kb_propose_claim / kb_propose_page citing that source
id. Report the proposal ids and note they await human review.
- You have no approve tool, and that is by design: reviewing is the human's
job. Do not offer to approve anything.
- Be terse. Ids in brackets, no restating tool output verbatim.
"""
TOOLS: list[dict[str, Any]] = [
{"type": "function", "function": {
"name": "kb_search",
"description": "Search the KB (claims, pages, entities). Returns scored hits.",
"parameters": {"type": "object", "properties": {
"query": {"type": "string"}, "limit": {"type": "integer", "default": 10}},
"required": ["query"]}}},
{"type": "function", "function": {
"name": "kb_context",
"description": "Assemble a cited context pack for a task or question.",
"parameters": {"type": "object", "properties": {
"task": {"type": "string"}, "limit": {"type": "integer", "default": 10}},
"required": ["task"]}}},
{"type": "function", "function": {
"name": "kb_read_claim",
"description": "Read one claim by id.",
"parameters": {"type": "object", "properties": {
"claim_id": {"type": "string"}}, "required": ["claim_id"]}}},
{"type": "function", "function": {
"name": "kb_read_page",
"description": "Read one page by id.",
"parameters": {"type": "object", "properties": {
"page_id": {"type": "string"}}, "required": ["page_id"]}}},
{"type": "function", "function": {
"name": "kb_list_pages",
"description": "List pages, filterable by kind and frontmatter "
"(e.g. type='followup', meta={'followup_status': 'open'}).",
"parameters": {"type": "object", "properties": {
"type": {"type": "string"},
"meta": {"type": "object"},
"meta_before": {"type": "object"},
"meta_after": {"type": "object"}}}}},
{"type": "function", "function": {
"name": "kb_digest",
"description": "Reviewer briefing: pending queue, recent decisions, stale claims, followups due.",
"parameters": {"type": "object", "properties": {
"since": {"type": "string", "default": "7d"},
"limit": {"type": "integer", "default": 10}}}}},
{"type": "function", "function": {
"name": "kb_list_pending",
"description": "List proposals awaiting human review.",
"parameters": {"type": "object", "properties": {}}}},
{"type": "function", "function": {
"name": "kb_register_source",
"description": "Register text as a content-addressed evidence source; returns its id.",
"parameters": {"type": "object", "properties": {
"content": {"type": "string"}, "title": {"type": "string"},
"source_type": {"type": "string", "default": "message"}},
"required": ["content", "title"]}}},
{"type": "function", "function": {
"name": "kb_propose_claim",
"description": "File a claim PROPOSAL citing evidence source ids. Lands pending; a human approves.",
"parameters": {"type": "object", "properties": {
"text": {"type": "string"},
"evidence": {"type": "array", "items": {"type": "string"}},
"tags": {"type": "array", "items": {"type": "string"}}},
"required": ["text", "evidence"]}}},
{"type": "function", "function": {
"name": "kb_propose_page",
"description": "File a page PROPOSAL (optionally a typed kind with metadata frontmatter). Lands pending.",
"parameters": {"type": "object", "properties": {
"title": {"type": "string"}, "body": {"type": "string"},
"page_type": {"type": "string", "default": "concept"},
"metadata": {"type": "object"},
"source_ids": {"type": "array", "items": {"type": "string"}}},
"required": ["title", "body"]}}},
]
_TOOL_TO_METHOD = {
"kb_search": "kb.search",
"kb_context": "kb.context",
"kb_read_claim": "kb.read_claim",
"kb_read_page": "kb.read_page",
"kb_list_pages": "kb.list_pages",
"kb_digest": "kb.digest",
"kb_list_pending": "kb.list_pending",
"kb_register_source": "kb.register_source",
"kb_propose_claim": "kb.propose_claim",
"kb_propose_page": "kb.propose_page",
}
class ChatRequest(BaseModel):
message: str
session_id: str = "default"
async def _llm(messages: list[dict[str, Any]]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{LLM_BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {LLM_API_KEY}"},
json={"model": LLM_MODEL, "messages": messages, "tools": TOOLS},
)
if resp.status_code != 200:
detail = resp.text[:300]
raise HTTPException(status_code=502, detail=f"model endpoint error: {detail}")
return resp.json()["choices"][0]["message"]
@app.post("/api/chat")
async def chat(req: ChatRequest) -> dict[str, Any]:
if not LLM_API_KEY:
raise HTTPException(
status_code=503,
detail="no model configured — set LLM_API_KEY (and optionally "
"LLM_BASE_URL / LLM_MODEL) to enable chat. Search and the "
"gate panel work without it.",
)
history = _sessions.setdefault(req.session_id, [])
history.append({"role": "user", "content": req.message})
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
*history[-MAX_HISTORY_MESSAGES:],
]
trace: list[dict[str, Any]] = []
for _ in range(MAX_TOOL_ROUNDS):
msg = await _llm(messages)
calls = msg.get("tool_calls")
if not calls:
reply = msg.get("content") or "(no reply)"
history.append({"role": "assistant", "content": reply})
return {"reply": reply, "trace": trace}
messages.append(msg)
for call in calls:
name = call["function"]["name"]
try:
args = json.loads(call["function"].get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
method = _TOOL_TO_METHOD.get(name)
if method is None:
payload: Any = {"error": f"unknown tool {name}"}
else:
try:
payload = await rpc(method, args)
except RpcError as e:
payload = {"error": str(e)}
trace.append({"tool": name, "args": args})
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": json.dumps(payload, default=str)[:20000],
})
history.append({"role": "assistant", "content": "(stopped: tool-round limit)"})
return {"reply": "stopped after too many tool rounds — try a narrower question",
"trace": trace}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host=os.environ.get("UI_HOST", "127.0.0.1"),
port=int(os.environ.get("UI_PORT", "8900")),
)