ltcai 9.7.0 → 9.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -316
- package/docs/CHANGELOG.md +37 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval.py +93 -4
- package/lattice_brain/graph/retrieval_vector.py +43 -0
- package/lattice_brain/ingestion.py +399 -14
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +12 -0
- package/latticeai/api/chat.py +17 -0
- package/latticeai/api/chat_helpers.py +48 -0
- package/latticeai/api/chat_stream.py +9 -1
- package/latticeai/api/local_files.py +120 -2
- package/latticeai/core/agent_eval.py +123 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +151 -14
- package/latticeai/services/brain_intelligence.py +72 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-B6c39ays.js → Act-Dd3z8AzF.js} +2 -2
- package/static/app/assets/Brain-BMkgdWnI.js +321 -0
- package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
- package/static/app/assets/Library-Yreq-KW5.js +1 -0
- package/static/app/assets/System-CXNmmtEo.js +1 -0
- package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
- package/static/app/assets/index-CndfILiF.js +18 -0
- package/static/app/assets/primitives-DxsIXb6G.js +1 -0
- package/static/app/assets/textarea-DH7ne8VI.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Brain-D7Qg4k6M.js +0 -321
- package/static/app/assets/Capture-VF_di68r.js +0 -1
- package/static/app/assets/Library-D_Gis2PA.js +0 -1
- package/static/app/assets/System-C5s5H2ov.js +0 -1
- package/static/app/assets/index-DJC_2oub.js +0 -18
- package/static/app/assets/primitives-DL4Nip8C.js +0 -1
- package/static/app/assets/textarea-woZfCXHy.js +0 -1
|
@@ -52,8 +52,14 @@ async def stream_chat(
|
|
|
52
52
|
history_meta: Optional[Dict[str, Any]] = None,
|
|
53
53
|
model_id: Optional[str] = None,
|
|
54
54
|
workspace_id: Optional[str] = None,
|
|
55
|
+
context_quality: Optional[Dict[str, Any]] = None,
|
|
55
56
|
) -> AsyncIterator[str]:
|
|
56
|
-
"""Stream model chunks and persist exactly one finalized answer.
|
|
57
|
+
"""Stream model chunks and persist exactly one finalized answer.
|
|
58
|
+
|
|
59
|
+
``context_quality`` (v9.8.0, additive) is echoed on the final trailer
|
|
60
|
+
event alongside the answer trace so streaming clients receive the same
|
|
61
|
+
honest RAG signal as the non-streaming JSON response.
|
|
62
|
+
"""
|
|
57
63
|
|
|
58
64
|
full_response = ""
|
|
59
65
|
stream_error: Optional[str] = None
|
|
@@ -106,6 +112,8 @@ async def stream_chat(
|
|
|
106
112
|
trailer: Dict[str, Any] = {"chunk": "", "model": model_id}
|
|
107
113
|
if trace_record:
|
|
108
114
|
trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
|
|
115
|
+
if context_quality is not None:
|
|
116
|
+
trailer["context_quality"] = context_quality
|
|
109
117
|
if stream_error:
|
|
110
118
|
trailer["error"] = stream_error
|
|
111
119
|
yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
|
|
@@ -10,7 +10,7 @@ from datetime import datetime
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Optional
|
|
12
12
|
|
|
13
|
-
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
|
14
14
|
from fastapi.responses import FileResponse
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
@@ -37,6 +37,18 @@ class LocalWriteRequest(BaseModel):
|
|
|
37
37
|
approval_token: Optional[str] = None
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
class FolderIngestRequest(BaseModel):
|
|
41
|
+
path: str
|
|
42
|
+
recursive: bool = True
|
|
43
|
+
background: bool = False
|
|
44
|
+
workspace_id: Optional[str] = None
|
|
45
|
+
# Local filesystem reads follow the standard approval dance (same as
|
|
46
|
+
# /local/read and /knowledge-graph/local/index): the first call returns a
|
|
47
|
+
# permission_required payload with an approval token.
|
|
48
|
+
approved: bool = False
|
|
49
|
+
approval_token: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
40
52
|
def create_local_files_router(
|
|
41
53
|
*,
|
|
42
54
|
require_user,
|
|
@@ -194,6 +206,107 @@ def create_local_files_router(
|
|
|
194
206
|
raise HTTPException(status_code=404, detail="File not found")
|
|
195
207
|
return FileResponse(str(target))
|
|
196
208
|
|
|
209
|
+
# ── v9.8.0 ingestion jobs API (frozen paths — consumed by the frontend) ───
|
|
210
|
+
def _require_pipeline():
|
|
211
|
+
if ingestion_pipeline is None or not ingestion_pipeline.available():
|
|
212
|
+
raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
|
|
213
|
+
|
|
214
|
+
def _ingestion_write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
|
|
215
|
+
header = request.headers.get("X-Workspace-Id")
|
|
216
|
+
header = header.strip() if header and header.strip() else None
|
|
217
|
+
supplied = [value for value in (body_workspace, header) if value]
|
|
218
|
+
if len(set(supplied)) > 1:
|
|
219
|
+
raise HTTPException(status_code=403, detail="Workspace selectors must match.")
|
|
220
|
+
requested = supplied[0] if supplied else None
|
|
221
|
+
if workspace_service is None:
|
|
222
|
+
return requested
|
|
223
|
+
try:
|
|
224
|
+
return workspace_service.resolve_write_scope(requested, user or None)
|
|
225
|
+
except PermissionError as exc:
|
|
226
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
227
|
+
|
|
228
|
+
@router.get("/api/ingestion/jobs")
|
|
229
|
+
async def ingestion_jobs(request: Request, limit: int = 20):
|
|
230
|
+
"""Recent background ingestion jobs (newest first)."""
|
|
231
|
+
require_user(request)
|
|
232
|
+
_require_pipeline()
|
|
233
|
+
limit = max(1, min(int(limit or 20), 100))
|
|
234
|
+
return {"jobs": ingestion_pipeline.list_background_jobs(limit=limit)}
|
|
235
|
+
|
|
236
|
+
@router.get("/api/ingestion/jobs/{job_id}")
|
|
237
|
+
async def ingestion_job_detail(job_id: str, request: Request):
|
|
238
|
+
"""One job with its progress counters and (capped) error records."""
|
|
239
|
+
require_user(request)
|
|
240
|
+
_require_pipeline()
|
|
241
|
+
job = ingestion_pipeline.get_background_job(job_id)
|
|
242
|
+
if job is None:
|
|
243
|
+
raise HTTPException(status_code=404, detail="ingestion job not found")
|
|
244
|
+
return job.as_dict()
|
|
245
|
+
|
|
246
|
+
@router.post("/api/ingestion/jobs/{job_id}/resume")
|
|
247
|
+
async def ingestion_job_resume(job_id: str, request: Request, background_tasks: BackgroundTasks):
|
|
248
|
+
"""Resume an interrupted/partial/failed job from its remaining items."""
|
|
249
|
+
user = require_user(request)
|
|
250
|
+
_require_pipeline()
|
|
251
|
+
job = ingestion_pipeline.get_background_job(job_id)
|
|
252
|
+
if job is None:
|
|
253
|
+
raise HTTPException(status_code=404, detail="ingestion job not found")
|
|
254
|
+
if job.status == "running":
|
|
255
|
+
return {"status": "already_running", "job_id": job_id, "job": job.as_dict()}
|
|
256
|
+
remaining = len(job.remaining_indices())
|
|
257
|
+
if remaining == 0 and job.status == "completed":
|
|
258
|
+
return {"status": "nothing_to_resume", "job_id": job_id, "job": job.as_dict()}
|
|
259
|
+
background_tasks.add_task(
|
|
260
|
+
ingestion_pipeline.resume_background_job, job_id, user_email=user or None,
|
|
261
|
+
)
|
|
262
|
+
return {
|
|
263
|
+
"status": "resuming",
|
|
264
|
+
"job_id": job_id,
|
|
265
|
+
"remaining": remaining,
|
|
266
|
+
"job": job.as_dict(),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@router.post("/api/ingestion/folder")
|
|
270
|
+
async def ingestion_folder(req: FolderIngestRequest, request: Request, background_tasks: BackgroundTasks):
|
|
271
|
+
"""Ingest a local folder through the unified pipeline.
|
|
272
|
+
|
|
273
|
+
Reads local disk, so it follows the same approval dance as
|
|
274
|
+
``/local/read`` and ``/knowledge-graph/local/index``: without
|
|
275
|
+
``approved`` + ``approval_token`` the response is a
|
|
276
|
+
``permission_required`` payload. ``background=true`` schedules a job
|
|
277
|
+
(summary includes ``job_id``) and executes it after the response.
|
|
278
|
+
"""
|
|
279
|
+
current_user = permission_gateway.require_local_user(request)
|
|
280
|
+
_require_pipeline()
|
|
281
|
+
workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
|
|
282
|
+
path = (req.path or "").strip()
|
|
283
|
+
if not path:
|
|
284
|
+
raise HTTPException(status_code=400, detail="path is required.")
|
|
285
|
+
if not req.approved:
|
|
286
|
+
return permission_gateway.local_permission_response(path, "read", current_user)
|
|
287
|
+
permission_gateway.require_local_approval(
|
|
288
|
+
token=req.approval_token,
|
|
289
|
+
path=path,
|
|
290
|
+
action="read",
|
|
291
|
+
user_email=current_user,
|
|
292
|
+
)
|
|
293
|
+
summary = ingestion_pipeline.ingest_folder(
|
|
294
|
+
path,
|
|
295
|
+
recursive=req.recursive,
|
|
296
|
+
background=req.background,
|
|
297
|
+
owner=current_user or None,
|
|
298
|
+
workspace_id=workspace_id,
|
|
299
|
+
user_email=current_user or None,
|
|
300
|
+
)
|
|
301
|
+
job_id = summary.get("job_id")
|
|
302
|
+
if req.background and job_id:
|
|
303
|
+
# Execute after the response is sent; progress is visible via
|
|
304
|
+
# GET /api/ingestion/jobs/{job_id}.
|
|
305
|
+
background_tasks.add_task(
|
|
306
|
+
ingestion_pipeline.run_background_job, job_id, user_email=current_user or None,
|
|
307
|
+
)
|
|
308
|
+
return summary
|
|
309
|
+
|
|
197
310
|
@router.post("/local/write")
|
|
198
311
|
async def local_write_endpoint(req: LocalWriteRequest, request: Request):
|
|
199
312
|
current_user = permission_gateway.require_local_user(request)
|
|
@@ -238,4 +351,9 @@ def create_local_files_router(
|
|
|
238
351
|
return router
|
|
239
352
|
|
|
240
353
|
|
|
241
|
-
__all__ = [
|
|
354
|
+
__all__ = [
|
|
355
|
+
"FolderIngestRequest",
|
|
356
|
+
"LocalAccessRequest",
|
|
357
|
+
"LocalWriteRequest",
|
|
358
|
+
"create_local_files_router",
|
|
359
|
+
]
|
|
@@ -52,6 +52,24 @@ _GOVERNED_WRITE_POLICY = {
|
|
|
52
52
|
"destructive": False, "sandbox": "workspace", "rollback": "git",
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
# Deterministic Brain-port fixtures. Grounding scenarios assert their final
|
|
56
|
+
# message against these exact values (node ids, concepts, snippets), so a
|
|
57
|
+
# response that was not derived from the tool result — a hallucination in
|
|
58
|
+
# loop terms — cannot pass.
|
|
59
|
+
_EVAL_INGEST_RESULT = {
|
|
60
|
+
"ok": True,
|
|
61
|
+
"node_id": "node-ing-1",
|
|
62
|
+
"concepts": ["mlx", "quantization"],
|
|
63
|
+
"relations": [{"from": "mlx", "to": "quantization", "type": "related_to"}],
|
|
64
|
+
}
|
|
65
|
+
_EVAL_SEARCH_RESULT = {
|
|
66
|
+
"ok": True,
|
|
67
|
+
"matches": [
|
|
68
|
+
{"id": "node-42", "title": "Q3 roadmap", "snippet": "ship v9.8 by August"}
|
|
69
|
+
],
|
|
70
|
+
"count": 1,
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
|
|
56
74
|
class _EvalChangeGovernor:
|
|
57
75
|
"""Deterministic stand-in for ChangeProposalService's governor port.
|
|
@@ -99,6 +117,11 @@ class Scenario:
|
|
|
99
117
|
expect_tool_calls: List[str] = field(default_factory=list)
|
|
100
118
|
# Wire a change governor so governed tools follow the proposal path.
|
|
101
119
|
use_governor: bool = False
|
|
120
|
+
# Substrings the final user-facing message must contain. Grounding
|
|
121
|
+
# scenarios point these at tokens that only exist in the fake tool port's
|
|
122
|
+
# canned results (node ids, snippets, proposal ids), so an answer that is
|
|
123
|
+
# not grounded in the retrieved/executed evidence fails the scenario.
|
|
124
|
+
expect_final_contains: List[str] = field(default_factory=list)
|
|
102
125
|
max_steps: int = 8
|
|
103
126
|
|
|
104
127
|
|
|
@@ -136,6 +159,12 @@ def _build_deps(
|
|
|
136
159
|
if name in ("write_file", "generate_file") and not args.get("path"):
|
|
137
160
|
raise ToolError(f"{name} requires args.path")
|
|
138
161
|
tool_log.append({"name": name, "args": args})
|
|
162
|
+
# Brain ports return canned, stable payloads so grounding scenarios
|
|
163
|
+
# can assert the final answer cites what was actually retrieved.
|
|
164
|
+
if name == "knowledge_graph_ingest":
|
|
165
|
+
return dict(_EVAL_INGEST_RESULT)
|
|
166
|
+
if name == "knowledge_graph_search":
|
|
167
|
+
return dict(_EVAL_SEARCH_RESULT)
|
|
139
168
|
return {"ok": True, "path": args.get("path", "")}
|
|
140
169
|
|
|
141
170
|
def policy_for(name: str, args: dict) -> dict:
|
|
@@ -157,6 +186,8 @@ def _build_deps(
|
|
|
157
186
|
"write_file": write_policy,
|
|
158
187
|
"read_file": dict(_AUTO_POLICY),
|
|
159
188
|
"generate_file": dict(_AUTO_POLICY),
|
|
189
|
+
"knowledge_graph_ingest": dict(_AUTO_POLICY),
|
|
190
|
+
"knowledge_graph_search": dict(_AUTO_POLICY),
|
|
160
191
|
"delete_everything": dict(_DESTRUCTIVE_POLICY),
|
|
161
192
|
},
|
|
162
193
|
file_create_actions=frozenset({"write_file", "generate_file"}),
|
|
@@ -333,6 +364,95 @@ def default_scenarios() -> List[Scenario]:
|
|
|
333
364
|
expect_tool_outcomes={"proposed": 1, "ok": 1},
|
|
334
365
|
expect_tool_calls=["write_file"],
|
|
335
366
|
),
|
|
367
|
+
# ── brain ingestion ─────────────────────────────────────────────
|
|
368
|
+
Scenario(
|
|
369
|
+
name="ingestion-chain-confirms-save",
|
|
370
|
+
replies=[
|
|
371
|
+
'{"action": "plan", "goal": "ingest web article into the Brain", '
|
|
372
|
+
'"steps": [{"action": "knowledge_graph_ingest"}]}',
|
|
373
|
+
# Weak-model dressing (think block) around a clean ingest call.
|
|
374
|
+
"<think>save the text first, then confirm with the node id</think>\n"
|
|
375
|
+
'{"thoughts": "ingest the pasted article", '
|
|
376
|
+
'"action": "knowledge_graph_ingest", "args": {"source": "web", '
|
|
377
|
+
'"url": "https://example.com/mlx-notes", '
|
|
378
|
+
'"text": "MLX quantization notes"}}',
|
|
379
|
+
# Confirmation cites the node id the ingest port returned —
|
|
380
|
+
# the loop must carry the tool result into the final turn.
|
|
381
|
+
'{"action": "final", "message": "Saved to the Brain as node-ing-1."}',
|
|
382
|
+
_PASS,
|
|
383
|
+
],
|
|
384
|
+
expect_exact={"parse_errors": 0},
|
|
385
|
+
expect_tool_outcomes={"ok": 1},
|
|
386
|
+
expect_tool_calls=["knowledge_graph_ingest"],
|
|
387
|
+
expect_final_contains=["node-ing-1"],
|
|
388
|
+
),
|
|
389
|
+
# ── concept extraction ──────────────────────────────────────────
|
|
390
|
+
Scenario(
|
|
391
|
+
name="concept-extraction-reflected-in-answer",
|
|
392
|
+
replies=[
|
|
393
|
+
'{"action": "plan", "goal": "ingest the note and report extracted concepts", '
|
|
394
|
+
'"steps": [{"action": "knowledge_graph_ingest"}]}',
|
|
395
|
+
'{"thoughts": "ingest the file so the pipeline extracts concepts", '
|
|
396
|
+
'"action": "knowledge_graph_ingest", "args": {"source": "file", '
|
|
397
|
+
'"path": "notes/mlx.md", "text": "quantization on MLX"}}',
|
|
398
|
+
# The confirmation must surface what the pipeline extracted
|
|
399
|
+
# (concepts + relation), not merely say "done".
|
|
400
|
+
'{"action": "final", "message": "Ingested notes/mlx.md - extracted '
|
|
401
|
+
'concepts mlx and quantization (mlx -> quantization, related_to)."}',
|
|
402
|
+
_PASS,
|
|
403
|
+
],
|
|
404
|
+
expect_exact={"parse_errors": 0},
|
|
405
|
+
expect_tool_outcomes={"ok": 1},
|
|
406
|
+
expect_tool_calls=["knowledge_graph_ingest"],
|
|
407
|
+
expect_final_contains=["mlx", "quantization", "related_to"],
|
|
408
|
+
),
|
|
409
|
+
# ── RAG-grounded answer ─────────────────────────────────────────
|
|
410
|
+
Scenario(
|
|
411
|
+
name="rag-grounded-answer-cites-retrieval",
|
|
412
|
+
replies=[
|
|
413
|
+
'{"action": "plan", "goal": "answer from the Brain, not from priors", '
|
|
414
|
+
'"steps": [{"action": "knowledge_graph_search"}]}',
|
|
415
|
+
'{"thoughts": "retrieve evidence before answering", '
|
|
416
|
+
'"action": "knowledge_graph_search", "args": {"query": "Q3 roadmap"}}',
|
|
417
|
+
# Grounded: cites the retrieved node id and snippet — both exist
|
|
418
|
+
# only in the fake port's canned search result, so an ungrounded
|
|
419
|
+
# (hallucinated) final message cannot satisfy the expectation.
|
|
420
|
+
'{"action": "final", "message": "Per Q3 roadmap (node-42): ship v9.8 by August."}',
|
|
421
|
+
_PASS,
|
|
422
|
+
],
|
|
423
|
+
expect_exact={"parse_errors": 0},
|
|
424
|
+
expect_tool_outcomes={"ok": 1},
|
|
425
|
+
expect_tool_calls=["knowledge_graph_search"],
|
|
426
|
+
expect_final_contains=["node-42", "ship v9.8 by August"],
|
|
427
|
+
),
|
|
428
|
+
# ── automation suggestion under proposal-first governance ───────
|
|
429
|
+
Scenario(
|
|
430
|
+
name="automation-suggestion-proposal-first",
|
|
431
|
+
use_governor=True,
|
|
432
|
+
replies=[
|
|
433
|
+
'{"action": "plan", "goal": "recurring daily question detected - stage a '
|
|
434
|
+
'digest automation for review", '
|
|
435
|
+
'"steps": [{"action": "knowledge_graph_search"}, {"action": "write_file"}]}',
|
|
436
|
+
# Evidence first: confirm the repeated pattern from the Brain.
|
|
437
|
+
'{"thoughts": "confirm the pattern is real before suggesting automation", '
|
|
438
|
+
'"action": "knowledge_graph_search", '
|
|
439
|
+
'"args": {"query": "recurring question daily digest"}}',
|
|
440
|
+
# Proposal-first: changing existing automation state is a
|
|
441
|
+
# mutation, so the governor stages it as a review proposal —
|
|
442
|
+
# the model can never silently install/enable an automation.
|
|
443
|
+
'{"thoughts": "stage the automation as a reviewable proposal", '
|
|
444
|
+
'"action": "write_file", '
|
|
445
|
+
'"args": {"path": "existing/automations/daily-digest.json", '
|
|
446
|
+
'"content": "trigger interval, disabled draft, review_queue"}}',
|
|
447
|
+
'{"action": "final", "message": "Automation staged for review as '
|
|
448
|
+
'eval-proposal-1 - enable it from the review queue."}',
|
|
449
|
+
_PASS,
|
|
450
|
+
],
|
|
451
|
+
expect_exact={"parse_errors": 0},
|
|
452
|
+
expect_tool_outcomes={"ok": 1, "proposed": 1},
|
|
453
|
+
expect_tool_calls=["knowledge_graph_search"],
|
|
454
|
+
expect_final_contains=["eval-proposal-1", "review"],
|
|
455
|
+
),
|
|
336
456
|
]
|
|
337
457
|
|
|
338
458
|
|
|
@@ -370,6 +490,9 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
|
|
|
370
490
|
executed = [call["name"] for call in tool_log]
|
|
371
491
|
if scenario.expect_tool_calls and executed != scenario.expect_tool_calls:
|
|
372
492
|
failures.append(f"tool_calls={executed} != {scenario.expect_tool_calls}")
|
|
493
|
+
for needle in scenario.expect_final_contains:
|
|
494
|
+
if needle not in ctx.final_message:
|
|
495
|
+
failures.append(f"final_message missing {needle!r}")
|
|
373
496
|
if governor is not None and summary["tool_outcomes"].get("proposed", 0) != len(governor.proposals):
|
|
374
497
|
failures.append(
|
|
375
498
|
f"governor proposals={len(governor.proposals)} != "
|
|
@@ -49,7 +49,7 @@ __all__ = [
|
|
|
49
49
|
"remove_skill_directory",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
WORKSPACE_OS_VERSION = "9.
|
|
52
|
+
WORKSPACE_OS_VERSION = "9.8.0"
|
|
53
53
|
|
|
54
54
|
# Workspace types separate single-user Personal workspaces from shared
|
|
55
55
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -22,6 +22,14 @@ automation suggestions:
|
|
|
22
22
|
|
|
23
23
|
Every suggestion id is deterministic, so install calls are idempotent and the
|
|
24
24
|
UI can safely re-request suggestions without duplicates.
|
|
25
|
+
|
|
26
|
+
v9.8.0 quality layer (additive): every suggestion carries a deterministic
|
|
27
|
+
``confidence`` score plus its ``confidence_factors`` evidence (repeat count,
|
|
28
|
+
distinct phrasings, intent match, related Brain nodes / indexed files);
|
|
29
|
+
suggestions below a minimum confidence are suppressed, duplicate suggestions
|
|
30
|
+
targeting an already-suggested or already-installed starter recipe are
|
|
31
|
+
deduplicated, and the response reports the suppression counters under
|
|
32
|
+
``quality``.
|
|
25
33
|
"""
|
|
26
34
|
|
|
27
35
|
from __future__ import annotations
|
|
@@ -40,6 +48,50 @@ _MIN_PATTERN_COUNT = 2
|
|
|
40
48
|
_MAX_HISTORY = 4000
|
|
41
49
|
_SIGNATURE_SIMILARITY = 0.6
|
|
42
50
|
|
|
51
|
+
# Suggestion quality gates (v9.8.0, additive). Suggestions below the minimum
|
|
52
|
+
# confidence are suppressed (insufficient evidence); suggestions between the
|
|
53
|
+
# minimum and the low-confidence threshold are shown but flagged so the UI
|
|
54
|
+
# can render them less prominently.
|
|
55
|
+
_MIN_SUGGESTION_CONFIDENCE = 0.35
|
|
56
|
+
_LOW_CONFIDENCE_THRESHOLD = 0.5
|
|
57
|
+
_KG_GROUNDING_LIMIT = 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _question_confidence(
|
|
61
|
+
count: int,
|
|
62
|
+
examples: List[str],
|
|
63
|
+
recipe_id: Optional[str],
|
|
64
|
+
kg_related: Optional[int],
|
|
65
|
+
) -> tuple:
|
|
66
|
+
"""Deterministic confidence for a recurring-question suggestion.
|
|
67
|
+
|
|
68
|
+
Evidence factors: how often the question repeats, how many distinct
|
|
69
|
+
phrasings exist, whether it maps onto a known starter-recipe intent, and
|
|
70
|
+
(when the graph is available) how many Brain nodes relate to it.
|
|
71
|
+
"""
|
|
72
|
+
score = 0.3 + 0.5 * min(1.0, (int(count) - 1) / 4)
|
|
73
|
+
score += min(0.15, 0.05 * len(examples or []))
|
|
74
|
+
if recipe_id:
|
|
75
|
+
score += 0.15
|
|
76
|
+
if kg_related:
|
|
77
|
+
score += min(0.2, 0.05 * int(kg_related))
|
|
78
|
+
factors = {
|
|
79
|
+
"repeat_count": int(count),
|
|
80
|
+
"distinct_examples": len(examples or []),
|
|
81
|
+
"intent_match": bool(recipe_id),
|
|
82
|
+
"kg_related_nodes": kg_related,
|
|
83
|
+
}
|
|
84
|
+
return round(min(1.0, score), 2), factors
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _source_confidence(indexed: int, watch_enabled: bool) -> tuple:
|
|
88
|
+
"""Deterministic confidence for a knowledge-source digest suggestion."""
|
|
89
|
+
score = 0.25 + 0.6 * min(1.0, int(indexed) / 25)
|
|
90
|
+
if watch_enabled:
|
|
91
|
+
score += 0.1
|
|
92
|
+
factors = {"indexed_files": int(indexed), "watch_enabled": bool(watch_enabled)}
|
|
93
|
+
return round(min(1.0, score), 2), factors
|
|
94
|
+
|
|
43
95
|
_QUESTION_HINT_RE = re.compile(
|
|
44
96
|
r"(\?|어때|뭐야|뭐가|뭘까|알려줘|보여줘|정리해|요약해|정리 좀|요약 좀|해줘"
|
|
45
97
|
r"|what|how|why|when|where|status|summar|remind|list|show me|tell me)",
|
|
@@ -223,38 +275,101 @@ class AutomationIntelligenceService:
|
|
|
223
275
|
LOGGER.exception("automation intelligence source read failed")
|
|
224
276
|
return []
|
|
225
277
|
|
|
278
|
+
def _kg_related_count(
|
|
279
|
+
self, text: str, *, workspace_id: Optional[str]
|
|
280
|
+
) -> Optional[int]:
|
|
281
|
+
"""Count Brain nodes related to a recurring question (grounding evidence).
|
|
282
|
+
|
|
283
|
+
Returns ``None`` when the graph is unavailable so confidence scoring can
|
|
284
|
+
distinguish "grounding impossible" from "grounded with zero hits".
|
|
285
|
+
Scoping mirrors the conversation-history read: an explicit workspace is
|
|
286
|
+
strict, no workspace falls back to the unscoped/legacy view.
|
|
287
|
+
"""
|
|
288
|
+
if not self._enable_graph or not hasattr(self._kg, "search"):
|
|
289
|
+
return None
|
|
290
|
+
try:
|
|
291
|
+
report = self._kg.search(
|
|
292
|
+
str(text or "")[:200],
|
|
293
|
+
limit=_KG_GROUNDING_LIMIT,
|
|
294
|
+
allowed_workspaces={workspace_id} if workspace_id is not None else None,
|
|
295
|
+
include_legacy_global=workspace_id is None,
|
|
296
|
+
)
|
|
297
|
+
except Exception:
|
|
298
|
+
LOGGER.exception("automation intelligence KG grounding failed")
|
|
299
|
+
return None
|
|
300
|
+
if not isinstance(report, dict):
|
|
301
|
+
return None
|
|
302
|
+
return len(report.get("matches") or [])
|
|
303
|
+
|
|
226
304
|
# ── suggestions ──────────────────────────────────────────────────────
|
|
227
305
|
|
|
228
|
-
def
|
|
229
|
-
|
|
306
|
+
def _installed_workflows(
|
|
307
|
+
self, *, workspace_id: Optional[str]
|
|
308
|
+
) -> tuple:
|
|
309
|
+
"""Map installed automation workflows by suggestion id and recipe id.
|
|
310
|
+
|
|
311
|
+
The recipe map lets a recurring-question suggestion that targets an
|
|
312
|
+
already-installed starter recipe surface as *installed* instead of
|
|
313
|
+
re-suggesting the same automation — the install API is idempotent on
|
|
314
|
+
exactly this provenance, so the read side must agree.
|
|
315
|
+
"""
|
|
316
|
+
by_suggestion: Dict[str, Dict[str, Any]] = {}
|
|
317
|
+
by_recipe: Dict[str, Dict[str, Any]] = {}
|
|
230
318
|
if self._store is None:
|
|
231
|
-
return
|
|
319
|
+
return by_suggestion, by_recipe
|
|
232
320
|
try:
|
|
233
321
|
workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
|
|
234
322
|
except Exception:
|
|
235
323
|
LOGGER.exception("automation intelligence workflow read failed")
|
|
236
|
-
return
|
|
324
|
+
return by_suggestion, by_recipe
|
|
237
325
|
for workflow in workflows:
|
|
238
326
|
metadata = (workflow or {}).get("metadata") or {}
|
|
239
|
-
|
|
240
|
-
if
|
|
241
|
-
|
|
242
|
-
|
|
327
|
+
created_from = metadata.get("created_from")
|
|
328
|
+
if created_from == "automation_suggestion" and metadata.get("suggestion_id"):
|
|
329
|
+
by_suggestion[str(metadata["suggestion_id"])] = workflow
|
|
330
|
+
elif created_from == "brain_automation_recipe" and metadata.get("recipe_id"):
|
|
331
|
+
by_recipe[str(metadata["recipe_id"])] = workflow
|
|
332
|
+
return by_suggestion, by_recipe
|
|
243
333
|
|
|
244
334
|
def suggestions(
|
|
245
335
|
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
246
336
|
) -> Dict[str, Any]:
|
|
247
337
|
pattern_report = self.question_patterns(user_email=user_email, workspace_id=workspace_id)
|
|
248
|
-
|
|
338
|
+
by_suggestion, by_recipe = self._installed_workflows(workspace_id=workspace_id)
|
|
249
339
|
|
|
250
340
|
items: List[Dict[str, Any]] = []
|
|
341
|
+
suppressed_low_confidence = 0
|
|
342
|
+
suppressed_duplicates = 0
|
|
343
|
+
seen_recipe_ids: set = set()
|
|
344
|
+
|
|
345
|
+
# Patterns arrive sorted by (count, last_asked) desc, so the first
|
|
346
|
+
# suggestion per recipe is the strongest — later ones targeting the
|
|
347
|
+
# same recipe would install the identical workflow and are suppressed.
|
|
251
348
|
for pattern in pattern_report["patterns"]:
|
|
349
|
+
recipe_id = pattern["recipe_id"]
|
|
350
|
+
if recipe_id and recipe_id in seen_recipe_ids:
|
|
351
|
+
suppressed_duplicates += 1
|
|
352
|
+
continue
|
|
353
|
+
kg_related = self._kg_related_count(
|
|
354
|
+
pattern["representative"], workspace_id=workspace_id
|
|
355
|
+
)
|
|
356
|
+
confidence, factors = _question_confidence(
|
|
357
|
+
pattern["count"], pattern["examples"], recipe_id, kg_related
|
|
358
|
+
)
|
|
359
|
+
if confidence < _MIN_SUGGESTION_CONFIDENCE:
|
|
360
|
+
suppressed_low_confidence += 1
|
|
361
|
+
continue
|
|
362
|
+
if recipe_id:
|
|
363
|
+
seen_recipe_ids.add(recipe_id)
|
|
252
364
|
suggestion_id = _stable_id("sug-q", pattern["id"])
|
|
365
|
+
workflow = by_suggestion.get(suggestion_id) or (
|
|
366
|
+
by_recipe.get(str(recipe_id)) if recipe_id else None
|
|
367
|
+
)
|
|
253
368
|
items.append({
|
|
254
369
|
"id": suggestion_id,
|
|
255
370
|
"kind": "recurring_question",
|
|
256
371
|
"intent": pattern["intent"],
|
|
257
|
-
"recipe_id":
|
|
372
|
+
"recipe_id": recipe_id,
|
|
258
373
|
"title": pattern["representative"],
|
|
259
374
|
"reason": {
|
|
260
375
|
"type": "repeated_question",
|
|
@@ -263,8 +378,11 @@ class AutomationIntelligenceService:
|
|
|
263
378
|
"examples": pattern["examples"],
|
|
264
379
|
},
|
|
265
380
|
"cadence": "daily",
|
|
266
|
-
"
|
|
267
|
-
"
|
|
381
|
+
"confidence": confidence,
|
|
382
|
+
"confidence_factors": factors,
|
|
383
|
+
"low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
|
|
384
|
+
"installed": workflow is not None,
|
|
385
|
+
"workflow_id": (workflow or {}).get("id"),
|
|
268
386
|
})
|
|
269
387
|
|
|
270
388
|
for source in self._knowledge_sources():
|
|
@@ -272,6 +390,14 @@ class AutomationIntelligenceService:
|
|
|
272
390
|
indexed = sum(int(v or 0) for v in file_status.values())
|
|
273
391
|
if indexed <= 0:
|
|
274
392
|
continue
|
|
393
|
+
confidence, factors = _source_confidence(
|
|
394
|
+
indexed, bool(source.get("watch_enabled"))
|
|
395
|
+
)
|
|
396
|
+
if confidence < _MIN_SUGGESTION_CONFIDENCE:
|
|
397
|
+
# A barely-indexed folder is not enough evidence for a digest
|
|
398
|
+
# automation yet — do not suggest.
|
|
399
|
+
suppressed_low_confidence += 1
|
|
400
|
+
continue
|
|
275
401
|
suggestion_id = _stable_id("sug-src", str(source.get("id") or source.get("root_path") or ""))
|
|
276
402
|
items.append({
|
|
277
403
|
"id": suggestion_id,
|
|
@@ -286,13 +412,22 @@ class AutomationIntelligenceService:
|
|
|
286
412
|
"watch_enabled": bool(source.get("watch_enabled")),
|
|
287
413
|
},
|
|
288
414
|
"cadence": "when new knowledge arrives",
|
|
289
|
-
"
|
|
290
|
-
"
|
|
415
|
+
"confidence": confidence,
|
|
416
|
+
"confidence_factors": factors,
|
|
417
|
+
"low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
|
|
418
|
+
"installed": suggestion_id in by_suggestion,
|
|
419
|
+
"workflow_id": (by_suggestion.get(suggestion_id) or {}).get("id"),
|
|
291
420
|
})
|
|
292
421
|
|
|
293
422
|
return {
|
|
294
423
|
"suggestions": items,
|
|
295
424
|
"questions_scanned": pattern_report["questions_scanned"],
|
|
425
|
+
"quality": {
|
|
426
|
+
"min_confidence": _MIN_SUGGESTION_CONFIDENCE,
|
|
427
|
+
"low_confidence_threshold": _LOW_CONFIDENCE_THRESHOLD,
|
|
428
|
+
"suppressed_low_confidence": suppressed_low_confidence,
|
|
429
|
+
"suppressed_duplicates": suppressed_duplicates,
|
|
430
|
+
},
|
|
296
431
|
"consent": {
|
|
297
432
|
"default_state": "draft_disabled",
|
|
298
433
|
"local_only": True,
|
|
@@ -403,6 +538,7 @@ class AutomationIntelligenceService:
|
|
|
403
538
|
"suggestion_kind": kind,
|
|
404
539
|
"suggestion_title": title,
|
|
405
540
|
"suggestion_reason": suggestion.get("reason"),
|
|
541
|
+
"suggestion_confidence": suggestion.get("confidence"),
|
|
406
542
|
"automation_state": "enabled" if enabled else "draft_disabled",
|
|
407
543
|
"local_only": True,
|
|
408
544
|
"external_actions": False,
|
|
@@ -442,6 +578,7 @@ class AutomationIntelligenceService:
|
|
|
442
578
|
"suggestions": suggestion_report["suggestions"],
|
|
443
579
|
"questions_scanned": suggestion_report["questions_scanned"],
|
|
444
580
|
"installed": installed,
|
|
581
|
+
"quality": suggestion_report["quality"],
|
|
445
582
|
"consent": suggestion_report["consent"],
|
|
446
583
|
"generated_at": _now(),
|
|
447
584
|
}
|