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.
Files changed (51) hide show
  1. package/README.md +98 -316
  2. package/docs/CHANGELOG.md +37 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/TRUST_MODEL.md +1 -1
  7. package/docs/WHY_LATTICE.md +1 -1
  8. package/docs/kg-schema.md +1 -1
  9. package/lattice_brain/__init__.py +1 -1
  10. package/lattice_brain/graph/retrieval.py +93 -4
  11. package/lattice_brain/graph/retrieval_vector.py +43 -0
  12. package/lattice_brain/ingestion.py +399 -14
  13. package/lattice_brain/runtime/multi_agent.py +1 -1
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/brain_intelligence.py +12 -0
  16. package/latticeai/api/chat.py +17 -0
  17. package/latticeai/api/chat_helpers.py +48 -0
  18. package/latticeai/api/chat_stream.py +9 -1
  19. package/latticeai/api/local_files.py +120 -2
  20. package/latticeai/core/agent_eval.py +123 -0
  21. package/latticeai/core/legacy_compatibility.py +1 -1
  22. package/latticeai/core/marketplace.py +1 -1
  23. package/latticeai/core/workspace_os.py +1 -1
  24. package/latticeai/services/architecture_readiness.py +1 -1
  25. package/latticeai/services/automation_intelligence.py +151 -14
  26. package/latticeai/services/brain_intelligence.py +72 -0
  27. package/latticeai/services/product_readiness.py +1 -1
  28. package/package.json +1 -1
  29. package/scripts/check_current_release_docs.mjs +1 -1
  30. package/src-tauri/Cargo.lock +1 -1
  31. package/src-tauri/Cargo.toml +1 -1
  32. package/src-tauri/tauri.conf.json +1 -1
  33. package/static/app/asset-manifest.json +11 -11
  34. package/static/app/assets/{Act-B6c39ays.js → Act-Dd3z8AzF.js} +2 -2
  35. package/static/app/assets/Brain-BMkgdWnI.js +321 -0
  36. package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
  37. package/static/app/assets/Library-Yreq-KW5.js +1 -0
  38. package/static/app/assets/System-CXNmmtEo.js +1 -0
  39. package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
  40. package/static/app/assets/index-CndfILiF.js +18 -0
  41. package/static/app/assets/primitives-DxsIXb6G.js +1 -0
  42. package/static/app/assets/textarea-DH7ne8VI.js +1 -0
  43. package/static/app/index.html +2 -2
  44. package/static/sw.js +1 -1
  45. package/static/app/assets/Brain-D7Qg4k6M.js +0 -321
  46. package/static/app/assets/Capture-VF_di68r.js +0 -1
  47. package/static/app/assets/Library-D_Gis2PA.js +0 -1
  48. package/static/app/assets/System-C5s5H2ov.js +0 -1
  49. package/static/app/assets/index-DJC_2oub.js +0 -18
  50. package/static/app/assets/primitives-DL4Nip8C.js +0 -1
  51. 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__ = ["LocalAccessRequest", "LocalWriteRequest", "create_local_files_router"]
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)} != "
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.7.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.8.0"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.7.0"
14
+ MARKETPLACE_VERSION = "9.8.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.7.0"
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
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.7.0"
20
+ ARCHITECTURE_VERSION_TARGET = "9.8.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -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 _installed_suggestion_ids(self, *, workspace_id: Optional[str]) -> Dict[str, Dict[str, Any]]:
229
- installed: Dict[str, Dict[str, Any]] = {}
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 installed
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 installed
324
+ return by_suggestion, by_recipe
237
325
  for workflow in workflows:
238
326
  metadata = (workflow or {}).get("metadata") or {}
239
- suggestion_id = metadata.get("suggestion_id")
240
- if metadata.get("created_from") == "automation_suggestion" and suggestion_id:
241
- installed[str(suggestion_id)] = workflow
242
- return installed
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
- installed = self._installed_suggestion_ids(workspace_id=workspace_id)
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": pattern["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
- "installed": suggestion_id in installed,
267
- "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
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
- "installed": suggestion_id in installed,
290
- "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
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
  }