ltcai 9.9.4 → 9.9.9
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 +43 -42
- package/docs/CHANGELOG.md +228 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +45 -9
- 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/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +28 -442
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +206 -48
- package/latticeai/api/chat_contracts.py +10 -1
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +536 -60
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +7 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +270 -23
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +129 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/scripts/run_sidecar_e2e.mjs +159 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -0
- 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 +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-BCmTU0E2.js +0 -1
- package/static/app/assets/Brain-CTnjox7w.js +0 -321
- package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
- package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
- package/static/app/assets/Capture-B6vBhFa3.js +0 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
- package/static/app/assets/Library-pKCK0_tk.js +0 -1
- package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
- package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
- package/static/app/assets/System-BAEuHqNY.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-DrmOCySv.js +0 -10
- package/static/app/assets/input-gtVCg-ll.js +0 -1
- package/static/app/assets/primitives-CX2Komon.js +0 -1
- package/static/app/assets/shield-alert-BftATuAA.js +0 -1
- package/static/app/assets/textarea-CiMJfOSI.js +0 -1
- package/static/app/assets/utils-SJUNVOj5.js +0 -7
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Wire PermissionMode into the running app without a large app_factory rewrite.
|
|
2
|
+
|
|
3
|
+
Creates one process-wide :class:`PermissionModeService`, points
|
|
4
|
+
``ToolDispatchService.permission_mode`` at it, and registers the HTTP dial.
|
|
5
|
+
Called from ``register_review_and_brain_tail_routers`` (which already has
|
|
6
|
+
``data_dir``, ``require_user``, ``append_audit_event``) and from
|
|
7
|
+
``build_chat_agent_runtime_from_context`` so the agent loop sees the same dial.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
from latticeai.core.permission_mode import DEFAULT_MODE, normalize_mode
|
|
18
|
+
from latticeai.services.permission_mode_service import PermissionModeService
|
|
19
|
+
|
|
20
|
+
_LOCK = threading.Lock()
|
|
21
|
+
_SHARED: Optional[PermissionModeService] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default_data_dir() -> Path:
|
|
25
|
+
raw = os.environ.get("LATTICEAI_DATA_DIR", "").strip()
|
|
26
|
+
if raw:
|
|
27
|
+
return Path(raw)
|
|
28
|
+
return Path.home() / ".ltcai"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _env_default_mode() -> Any:
|
|
32
|
+
return normalize_mode(os.environ.get("LATTICEAI_PERMISSION_MODE", DEFAULT_MODE.value))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_permission_mode_service(
|
|
36
|
+
*,
|
|
37
|
+
data_dir: Optional[Path] = None,
|
|
38
|
+
audit: Optional[Callable[..., None]] = None,
|
|
39
|
+
) -> PermissionModeService:
|
|
40
|
+
"""Process-wide singleton.
|
|
41
|
+
|
|
42
|
+
An early lazy caller (a tool dispatch before routers are mounted) would
|
|
43
|
+
otherwise pin the service to the fallback ``data_dir`` with no audit sink.
|
|
44
|
+
Explicit arguments therefore *rebind* an already-created service instead of
|
|
45
|
+
being silently dropped, so the app's real data dir and audit log always win.
|
|
46
|
+
"""
|
|
47
|
+
global _SHARED
|
|
48
|
+
with _LOCK:
|
|
49
|
+
if _SHARED is None:
|
|
50
|
+
_SHARED = PermissionModeService(
|
|
51
|
+
data_dir=Path(data_dir) if data_dir is not None else _default_data_dir(),
|
|
52
|
+
default_mode=_env_default_mode(),
|
|
53
|
+
audit=audit,
|
|
54
|
+
)
|
|
55
|
+
return _SHARED
|
|
56
|
+
if data_dir is not None:
|
|
57
|
+
_SHARED.rebind_data_dir(Path(data_dir))
|
|
58
|
+
if audit is not None:
|
|
59
|
+
_SHARED.rebind_audit(audit)
|
|
60
|
+
return _SHARED
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_active_permission_mode(
|
|
64
|
+
*,
|
|
65
|
+
user_email: Optional[str] = None,
|
|
66
|
+
workspace_id: Optional[str] = None,
|
|
67
|
+
) -> Any:
|
|
68
|
+
return get_permission_mode_service().resolve(
|
|
69
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bind_dispatch_permission_mode(dispatch_service: Any = None) -> None:
|
|
74
|
+
"""Point tool dispatch at the shared mode resolver.
|
|
75
|
+
|
|
76
|
+
The bound callable accepts scope kwargs so per-user and per-workspace
|
|
77
|
+
overrides actually reach ``enforce_policy`` — an unscoped resolver would
|
|
78
|
+
always return the process-wide default and make the dial a no-op.
|
|
79
|
+
"""
|
|
80
|
+
from latticeai.services.tool_dispatch import DEFAULT_TOOL_DISPATCH_SERVICE
|
|
81
|
+
|
|
82
|
+
service = dispatch_service or DEFAULT_TOOL_DISPATCH_SERVICE
|
|
83
|
+
service.permission_mode = resolve_active_permission_mode
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register_permission_mode_router(
|
|
87
|
+
app: Any,
|
|
88
|
+
*,
|
|
89
|
+
require_user: Callable[..., str],
|
|
90
|
+
data_dir: Optional[Path] = None,
|
|
91
|
+
append_audit_event: Optional[Callable[..., None]] = None,
|
|
92
|
+
) -> Any:
|
|
93
|
+
"""Install GET/POST /api/permission-mode on ``app``. Idempotent by route path."""
|
|
94
|
+
from latticeai.api.permission_mode import create_permission_mode_router
|
|
95
|
+
|
|
96
|
+
svc = get_permission_mode_service(data_dir=data_dir, audit=append_audit_event)
|
|
97
|
+
bind_dispatch_permission_mode()
|
|
98
|
+
# Skip if already mounted (re-entrant factory / tests).
|
|
99
|
+
existing = {
|
|
100
|
+
getattr(route, "path", None)
|
|
101
|
+
for route in getattr(app, "routes", ())
|
|
102
|
+
}
|
|
103
|
+
if "/api/permission-mode" in existing:
|
|
104
|
+
return svc
|
|
105
|
+
app.include_router(
|
|
106
|
+
create_permission_mode_router(service=svc, require_user=require_user)
|
|
107
|
+
)
|
|
108
|
+
return svc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = [
|
|
112
|
+
"get_permission_mode_service",
|
|
113
|
+
"resolve_active_permission_mode",
|
|
114
|
+
"bind_dispatch_permission_mode",
|
|
115
|
+
"register_permission_mode_router",
|
|
116
|
+
]
|
|
@@ -653,4 +653,13 @@ def register_review_and_brain_tail_routers(
|
|
|
653
653
|
workspace_service=workspace_service,
|
|
654
654
|
),
|
|
655
655
|
)
|
|
656
|
+
# Permission mode dial (v9.9.8): mount last so data_dir + audit are known.
|
|
657
|
+
from latticeai.runtime.permission_mode_wiring import register_permission_mode_router
|
|
658
|
+
|
|
659
|
+
register_permission_mode_router(
|
|
660
|
+
app,
|
|
661
|
+
require_user=require_user,
|
|
662
|
+
data_dir=data_dir,
|
|
663
|
+
append_audit_event=append_audit_event,
|
|
664
|
+
)
|
|
656
665
|
return brain_network
|
|
@@ -33,6 +33,9 @@ class AppContext:
|
|
|
33
33
|
local_kg_watcher: Any = None
|
|
34
34
|
chat_service: Any = None
|
|
35
35
|
context_assembler: Any = None
|
|
36
|
+
# Re-search loop (v9.9.6): conversation-scoped ledger of just-written
|
|
37
|
+
# artifacts, so a follow-up turn sees them before indexing catches up.
|
|
38
|
+
artifact_ledger: Any = None
|
|
36
39
|
brain_memory: Any = None
|
|
37
40
|
# Unified ingestion gateway (lattice_brain.ingestion.IngestionPipeline);
|
|
38
41
|
# None when the knowledge graph is disabled.
|
|
@@ -391,6 +391,111 @@ class BrainIntelligenceService:
|
|
|
391
391
|
"generated_at": _now(),
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
def garden_overview(
|
|
395
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None,
|
|
396
|
+
limit: int = 8,
|
|
397
|
+
) -> Dict[str, Any]:
|
|
398
|
+
"""The knowledge garden in four beds (v9.9.7).
|
|
399
|
+
|
|
400
|
+
Living Brain answers "how healthy is my knowledge?" in aggregate.
|
|
401
|
+
A gardener asks four concrete questions instead, and this answers all
|
|
402
|
+
four from one workspace-scoped graph sample plus the memory tier:
|
|
403
|
+
|
|
404
|
+
* **recent** — what came in lately (the garden's new growth);
|
|
405
|
+
* **contradictions** — what disagrees with itself (needs weeding);
|
|
406
|
+
* **stale** — what has not been touched in a long time;
|
|
407
|
+
* **frequent** — what the rest of the graph leans on most (by degree).
|
|
408
|
+
|
|
409
|
+
Read-only and honest: when the graph is unavailable every bed is empty
|
|
410
|
+
and ``available`` is false — the view never invents plants.
|
|
411
|
+
"""
|
|
412
|
+
# Explicit 0 clamps to 1 — `limit or 8` would silently re-expand it.
|
|
413
|
+
try:
|
|
414
|
+
limit = max(1, min(int(limit), 50))
|
|
415
|
+
except (TypeError, ValueError):
|
|
416
|
+
limit = 8
|
|
417
|
+
sample = self._graph_sample(workspace_id=workspace_id)
|
|
418
|
+
nodes, edges = sample["nodes"], sample["edges"]
|
|
419
|
+
now = datetime.now(timezone.utc)
|
|
420
|
+
recent_cutoff = now - timedelta(days=_RECENT_DAYS)
|
|
421
|
+
stale_cutoff = now - timedelta(days=_STALE_DAYS)
|
|
422
|
+
|
|
423
|
+
def _slim(node: Dict[str, Any], **extra: Any) -> Dict[str, Any]:
|
|
424
|
+
return {
|
|
425
|
+
"id": node.get("id"),
|
|
426
|
+
"type": node.get("type"),
|
|
427
|
+
"title": str(node.get("title") or "")[:120],
|
|
428
|
+
"updated_at": node.get("updated_at"),
|
|
429
|
+
**extra,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
recent: List[Dict[str, Any]] = []
|
|
433
|
+
stale: List[Dict[str, Any]] = []
|
|
434
|
+
for node in nodes:
|
|
435
|
+
# Chunks are retrieval plumbing, not knowledge a gardener tends.
|
|
436
|
+
if str(node.get("type") or "") == "Chunk":
|
|
437
|
+
continue
|
|
438
|
+
ts = _parse_ts(node.get("updated_at"))
|
|
439
|
+
if ts is None:
|
|
440
|
+
continue
|
|
441
|
+
if ts >= recent_cutoff:
|
|
442
|
+
recent.append(node)
|
|
443
|
+
elif ts < stale_cutoff:
|
|
444
|
+
stale.append(node)
|
|
445
|
+
recent.sort(key=lambda n: str(n.get("updated_at") or ""), reverse=True)
|
|
446
|
+
stale.sort(key=lambda n: str(n.get("updated_at") or ""))
|
|
447
|
+
|
|
448
|
+
# "Frequent" is degree, not a guess: how many relations actually point
|
|
449
|
+
# at a node. Chunks are retrieval plumbing, never garden plants.
|
|
450
|
+
degree: Dict[str, int] = {}
|
|
451
|
+
for edge in edges:
|
|
452
|
+
for key in ("source", "target"):
|
|
453
|
+
node_id = str(edge.get(key) or "")
|
|
454
|
+
if node_id:
|
|
455
|
+
degree[node_id] = degree.get(node_id, 0) + 1
|
|
456
|
+
by_id = {str(node.get("id")): node for node in nodes}
|
|
457
|
+
frequent = [
|
|
458
|
+
_slim(by_id[node_id], degree=count)
|
|
459
|
+
for node_id, count in sorted(degree.items(), key=lambda kv: kv[1], reverse=True)
|
|
460
|
+
if node_id in by_id and str(by_id[node_id].get("type") or "") != "Chunk"
|
|
461
|
+
][:limit]
|
|
462
|
+
|
|
463
|
+
contradiction_items: List[Dict[str, Any]] = []
|
|
464
|
+
contradiction_count = 0
|
|
465
|
+
try:
|
|
466
|
+
found = self.contradictions(user_email=user_email, workspace_id=workspace_id)
|
|
467
|
+
items = found.get("items") if isinstance(found, dict) else None
|
|
468
|
+
if isinstance(items, list):
|
|
469
|
+
contradiction_count = len(items)
|
|
470
|
+
contradiction_items = items[:limit]
|
|
471
|
+
except Exception: # noqa: BLE001 — one empty bed, never a broken view
|
|
472
|
+
LOGGER.exception("garden overview contradictions failed")
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
"available": sample["available"],
|
|
476
|
+
"window_days": _RECENT_DAYS,
|
|
477
|
+
"stale_threshold_days": _STALE_DAYS,
|
|
478
|
+
"beds": {
|
|
479
|
+
"recent": {
|
|
480
|
+
"count": len(recent),
|
|
481
|
+
"items": [_slim(node) for node in recent[:limit]],
|
|
482
|
+
},
|
|
483
|
+
"contradictions": {
|
|
484
|
+
"count": contradiction_count,
|
|
485
|
+
"items": contradiction_items,
|
|
486
|
+
},
|
|
487
|
+
"stale": {
|
|
488
|
+
"count": len(stale),
|
|
489
|
+
"items": [_slim(node) for node in stale[:limit]],
|
|
490
|
+
},
|
|
491
|
+
"frequent": {
|
|
492
|
+
"count": len(frequent),
|
|
493
|
+
"items": frequent,
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
"generated_at": _now(),
|
|
497
|
+
}
|
|
498
|
+
|
|
394
499
|
# ── graph-layer proactive quality (v9.6.x) ───────────────────────────
|
|
395
500
|
|
|
396
501
|
def graph_duplicates(
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Evidence → action bridge (v9.9.6).
|
|
2
|
+
|
|
3
|
+
Answers already carry citations and a grounding badge, and clicking a source
|
|
4
|
+
already opens the stored chunk. The loop stopped there: to *use* the evidence
|
|
5
|
+
the user had to retype the request and hope the model retrieved the same
|
|
6
|
+
sources again.
|
|
7
|
+
|
|
8
|
+
This service closes that gap deterministically. Given the citations an answer
|
|
9
|
+
actually used, it resolves them against the graph and composes ready-to-send,
|
|
10
|
+
evidence-scoped prompts — "이 근거로 요약 만들기", "체크리스트 만들기",
|
|
11
|
+
"문서 파일 만들기", "한 페이지로 만들기". No model runs here: the output is a
|
|
12
|
+
prompt plus the resolved evidence, so the existing chat/file-generation path
|
|
13
|
+
stays the single execution road.
|
|
14
|
+
|
|
15
|
+
Honesty rules:
|
|
16
|
+
|
|
17
|
+
* Citations that no longer resolve are reported in ``missing`` — never
|
|
18
|
+
silently dropped, and never invented.
|
|
19
|
+
* When nothing resolves, ``actions`` is empty and ``reason`` says why. The UI
|
|
20
|
+
must not offer an action the Brain cannot ground.
|
|
21
|
+
* Every composed prompt tells the model to use only the quoted evidence and
|
|
22
|
+
to say so when the evidence does not cover the request.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
|
30
|
+
|
|
31
|
+
LOGGER = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
__all__ = ["EvidenceActionService", "EVIDENCE_ACTIONS"]
|
|
34
|
+
|
|
35
|
+
# Per-source excerpt cap. Long enough to carry the claim, short enough that a
|
|
36
|
+
# four-citation prompt still fits a small local model's context.
|
|
37
|
+
_EXCERPT_CHARS = 600
|
|
38
|
+
_MAX_SOURCES = 8
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _phrase(ko: str, en: str) -> Dict[str, str]:
|
|
42
|
+
return {"ko": ko, "en": en}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# The action catalog is closed and deterministic — the UI renders exactly
|
|
46
|
+
# these, and each one names the artifact it produces.
|
|
47
|
+
EVIDENCE_ACTIONS: List[Dict[str, Any]] = [
|
|
48
|
+
{
|
|
49
|
+
"id": "summary",
|
|
50
|
+
"label": _phrase("이 근거로 요약 만들기", "Summarize from this evidence"),
|
|
51
|
+
"instruction": _phrase(
|
|
52
|
+
"핵심만 5줄 이내로 요약해 주세요.",
|
|
53
|
+
"Summarize the key points in five lines or fewer.",
|
|
54
|
+
),
|
|
55
|
+
"kind": "chat",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "checklist",
|
|
59
|
+
"label": _phrase("이 근거로 체크리스트 만들기", "Build a checklist"),
|
|
60
|
+
"instruction": _phrase(
|
|
61
|
+
"실행 가능한 체크리스트를 만들어 주세요. 각 항목은 한 줄이고, 근거가 있는 항목만 넣으세요.",
|
|
62
|
+
"Build an actionable checklist. One line per item, only items the evidence supports.",
|
|
63
|
+
),
|
|
64
|
+
"kind": "chat",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "document",
|
|
68
|
+
"label": _phrase("이 근거로 문서 파일 만들기", "Write a document file"),
|
|
69
|
+
"instruction": _phrase(
|
|
70
|
+
"정리된 마크다운 문서를 만들어 {path} 파일로 저장해 주세요.",
|
|
71
|
+
"Write a structured markdown document and save it as {path}.",
|
|
72
|
+
),
|
|
73
|
+
"kind": "file",
|
|
74
|
+
"extension": ".md",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "page",
|
|
78
|
+
"label": _phrase("이 근거로 한 페이지 만들기", "Build a one-page view"),
|
|
79
|
+
"instruction": _phrase(
|
|
80
|
+
"내용을 한눈에 보는 HTML 한 페이지로 만들어 {path} 파일로 저장해 주세요.",
|
|
81
|
+
"Build a single self-contained HTML page and save it as {path}.",
|
|
82
|
+
),
|
|
83
|
+
"kind": "file",
|
|
84
|
+
"extension": ".html",
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def slugify(text: str, *, fallback: str = "evidence-note") -> str:
|
|
92
|
+
"""Deterministic, filesystem-safe stem for a suggested artifact path.
|
|
93
|
+
|
|
94
|
+
Non-ASCII text (Korean questions are the common case) leaves nothing to
|
|
95
|
+
slug — the fallback keeps the suggestion predictable instead of producing
|
|
96
|
+
a mangled or empty filename.
|
|
97
|
+
"""
|
|
98
|
+
slug = _SLUG_STRIP_RE.sub("-", str(text or "").lower()).strip("-")
|
|
99
|
+
slug = "-".join(part for part in slug.split("-") if part)[:48].strip("-")
|
|
100
|
+
return slug or fallback
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class EvidenceActionService:
|
|
104
|
+
"""Compose evidence-scoped follow-up actions for an answer's citations."""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
node_reader: Optional[Callable[..., Mapping[str, Any]]] = None,
|
|
110
|
+
excerpt_chars: int = _EXCERPT_CHARS,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._node_reader = node_reader
|
|
113
|
+
self._excerpt_chars = max(120, int(excerpt_chars))
|
|
114
|
+
|
|
115
|
+
# ── evidence resolution ──────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def _read_node(self, node_id: str, allowed_workspaces: Any) -> Optional[Mapping[str, Any]]:
|
|
118
|
+
if self._node_reader is None:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
return self._node_reader(node_id, allowed_workspaces=allowed_workspaces)
|
|
122
|
+
except TypeError:
|
|
123
|
+
# Readers without scope support (tests, older ports).
|
|
124
|
+
try:
|
|
125
|
+
return self._node_reader(node_id)
|
|
126
|
+
except Exception as exc: # noqa: BLE001 — resolution is best-effort
|
|
127
|
+
LOGGER.debug("evidence node read failed for %s: %s", node_id, exc)
|
|
128
|
+
return None
|
|
129
|
+
except Exception as exc: # noqa: BLE001
|
|
130
|
+
LOGGER.debug("evidence node read failed for %s: %s", node_id, exc)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def resolve(
|
|
134
|
+
self,
|
|
135
|
+
source_ids: Sequence[str],
|
|
136
|
+
*,
|
|
137
|
+
allowed_workspaces: Any = None,
|
|
138
|
+
) -> Dict[str, Any]:
|
|
139
|
+
"""Resolve citation ids to ``{"sources": [...], "missing": [...]}``."""
|
|
140
|
+
sources: List[Dict[str, Any]] = []
|
|
141
|
+
missing: List[str] = []
|
|
142
|
+
seen: set = set()
|
|
143
|
+
for raw in list(source_ids or [])[:_MAX_SOURCES]:
|
|
144
|
+
node_id = str(raw or "").strip()
|
|
145
|
+
if not node_id or node_id in seen:
|
|
146
|
+
continue
|
|
147
|
+
seen.add(node_id)
|
|
148
|
+
node = self._read_node(node_id, allowed_workspaces)
|
|
149
|
+
if not isinstance(node, Mapping):
|
|
150
|
+
missing.append(node_id)
|
|
151
|
+
continue
|
|
152
|
+
# /api/graph/node wraps the record; get_node returns it directly.
|
|
153
|
+
record = node.get("node") if isinstance(node.get("node"), Mapping) else node
|
|
154
|
+
title = str(record.get("title") or record.get("id") or node_id)
|
|
155
|
+
body = str(record.get("summary") or record.get("content") or "").strip()
|
|
156
|
+
metadata = record.get("metadata")
|
|
157
|
+
metadata = metadata if isinstance(metadata, Mapping) else {}
|
|
158
|
+
origin = ""
|
|
159
|
+
for key in ("relative_path", "file_path", "filename", "source_uri", "source"):
|
|
160
|
+
value = metadata.get(key)
|
|
161
|
+
if value:
|
|
162
|
+
origin = str(value)
|
|
163
|
+
break
|
|
164
|
+
sources.append({
|
|
165
|
+
"id": node_id,
|
|
166
|
+
"title": title,
|
|
167
|
+
"type": str(record.get("type") or ""),
|
|
168
|
+
"origin": origin,
|
|
169
|
+
"excerpt": body[: self._excerpt_chars],
|
|
170
|
+
"truncated": len(body) > self._excerpt_chars,
|
|
171
|
+
})
|
|
172
|
+
return {"sources": sources, "missing": missing}
|
|
173
|
+
|
|
174
|
+
# ── prompt composition ───────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def _evidence_block(self, sources: Sequence[Mapping[str, Any]], language: str) -> str:
|
|
177
|
+
header = "[근거 자료]" if language == "ko" else "[EVIDENCE]"
|
|
178
|
+
lines = [header]
|
|
179
|
+
for index, source in enumerate(sources, start=1):
|
|
180
|
+
origin = f" ({source['origin']})" if source.get("origin") else ""
|
|
181
|
+
lines.append(f"{index}. {source['title']}{origin}")
|
|
182
|
+
excerpt = str(source.get("excerpt") or "").strip()
|
|
183
|
+
if excerpt:
|
|
184
|
+
suffix = " …" if source.get("truncated") else ""
|
|
185
|
+
lines.append(f" {excerpt}{suffix}")
|
|
186
|
+
return "\n".join(lines)
|
|
187
|
+
|
|
188
|
+
def _guard(self, language: str) -> str:
|
|
189
|
+
if language == "ko":
|
|
190
|
+
return (
|
|
191
|
+
"위 근거 자료에 있는 내용만 사용하세요. 근거에 없는 사실은 지어내지 말고, "
|
|
192
|
+
"근거가 부족하면 '이 부분은 근거가 없습니다'라고 적으세요."
|
|
193
|
+
)
|
|
194
|
+
return (
|
|
195
|
+
"Use only the evidence quoted above. Do not invent facts that are not in it; "
|
|
196
|
+
"when the evidence does not cover something, say so explicitly."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def actions_for(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
question: str = "",
|
|
203
|
+
source_ids: Sequence[str] = (),
|
|
204
|
+
language: str = "ko",
|
|
205
|
+
allowed_workspaces: Any = None,
|
|
206
|
+
) -> Dict[str, Any]:
|
|
207
|
+
"""Ready-to-send, evidence-scoped follow-up actions for one answer."""
|
|
208
|
+
language = "ko" if str(language or "ko").lower().startswith("ko") else "en"
|
|
209
|
+
resolved = self.resolve(source_ids, allowed_workspaces=allowed_workspaces)
|
|
210
|
+
sources = resolved["sources"]
|
|
211
|
+
question_text = str(question or "").strip()
|
|
212
|
+
if not sources:
|
|
213
|
+
return {
|
|
214
|
+
"sources": [],
|
|
215
|
+
"missing": resolved["missing"],
|
|
216
|
+
"actions": [],
|
|
217
|
+
"reason": (
|
|
218
|
+
"근거로 쓸 출처를 찾지 못했습니다."
|
|
219
|
+
if language == "ko"
|
|
220
|
+
else "No usable evidence could be resolved."
|
|
221
|
+
),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
evidence = self._evidence_block(sources, language)
|
|
225
|
+
guard = self._guard(language)
|
|
226
|
+
stem = slugify(question_text)
|
|
227
|
+
actions: List[Dict[str, Any]] = []
|
|
228
|
+
for spec in EVIDENCE_ACTIONS:
|
|
229
|
+
path = f"{stem}{spec['extension']}" if spec.get("extension") else ""
|
|
230
|
+
instruction = spec["instruction"][language].replace("{path}", path)
|
|
231
|
+
question_line = (
|
|
232
|
+
(f"원래 질문: {question_text}" if language == "ko" else f"Original question: {question_text}")
|
|
233
|
+
if question_text
|
|
234
|
+
else ""
|
|
235
|
+
)
|
|
236
|
+
prompt = "\n\n".join(
|
|
237
|
+
part for part in (evidence, instruction, guard, question_line) if part
|
|
238
|
+
)
|
|
239
|
+
action: Dict[str, Any] = {
|
|
240
|
+
"id": spec["id"],
|
|
241
|
+
"kind": spec["kind"],
|
|
242
|
+
"label": dict(spec["label"]),
|
|
243
|
+
"prompt": prompt,
|
|
244
|
+
"source_ids": [source["id"] for source in sources],
|
|
245
|
+
}
|
|
246
|
+
if path:
|
|
247
|
+
action["suggested_path"] = path
|
|
248
|
+
actions.append(action)
|
|
249
|
+
return {
|
|
250
|
+
"sources": sources,
|
|
251
|
+
"missing": resolved["missing"],
|
|
252
|
+
"actions": actions,
|
|
253
|
+
"reason": "",
|
|
254
|
+
}
|
|
@@ -181,30 +181,125 @@ class FunnelMetricsService:
|
|
|
181
181
|
return round(delta, 1) if delta >= 0 else None
|
|
182
182
|
|
|
183
183
|
def snapshot(self) -> Dict[str, Any]:
|
|
184
|
-
"""Counters + derived rates for the admin surface."""
|
|
184
|
+
"""Counters + derived rates + actionable alerts for the admin surface."""
|
|
185
185
|
with self._lock:
|
|
186
186
|
counters = {name: int(self._state.get(name) or 0) for name in COUNTER_NAMES}
|
|
187
187
|
firsts = {name: self._state.get(name) for name in _FIRST_NAMES}
|
|
188
|
+
rates = {
|
|
189
|
+
"real_file_rate": _rate(
|
|
190
|
+
counters["real_file_delivered"], counters["file_requests"]
|
|
191
|
+
),
|
|
192
|
+
"code_only_rate": _rate(
|
|
193
|
+
counters["code_only_responses"], counters["file_requests"]
|
|
194
|
+
),
|
|
195
|
+
"needs_review_rate": _rate(
|
|
196
|
+
counters["needs_review_runs"], counters["agent_runs"]
|
|
197
|
+
),
|
|
198
|
+
"approval_resume_rate": _rate(
|
|
199
|
+
counters["approval_resumes"], counters["approval_pauses"]
|
|
200
|
+
),
|
|
201
|
+
}
|
|
188
202
|
return {
|
|
189
203
|
"counters": counters,
|
|
190
204
|
"firsts": firsts,
|
|
191
|
-
"rates":
|
|
192
|
-
|
|
193
|
-
counters["real_file_delivered"], counters["file_requests"]
|
|
194
|
-
),
|
|
195
|
-
"code_only_rate": _rate(
|
|
196
|
-
counters["code_only_responses"], counters["file_requests"]
|
|
197
|
-
),
|
|
198
|
-
"needs_review_rate": _rate(
|
|
199
|
-
counters["needs_review_runs"], counters["agent_runs"]
|
|
200
|
-
),
|
|
201
|
-
"approval_resume_rate": _rate(
|
|
202
|
-
counters["approval_resumes"], counters["approval_pauses"]
|
|
203
|
-
),
|
|
204
|
-
},
|
|
205
|
+
"rates": rates,
|
|
206
|
+
"alerts": funnel_alerts(counters, rates),
|
|
205
207
|
"ttfv_seconds": self.ttfv_seconds(),
|
|
206
208
|
"generated_at": _utc_now_iso(),
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
# ── alerts (review 2026-07-27 P2 #10) ────────────────────────────────────────
|
|
213
|
+
# The funnel already measured the right things; nothing turned a bad number
|
|
214
|
+
# into a decision, so a regression was only visible to whoever opened the
|
|
215
|
+
# admin page. These thresholds convert rates into named, actionable signals.
|
|
216
|
+
#
|
|
217
|
+
# Minimum samples per rule exist so a single unlucky run never raises an
|
|
218
|
+
# alarm — an alert with n=1 is noise, and noisy alerts get ignored.
|
|
219
|
+
|
|
220
|
+
REAL_FILE_RATE_FLOOR = 0.95
|
|
221
|
+
CODE_ONLY_RATE_CEILING = 0.05
|
|
222
|
+
NEEDS_REVIEW_RATE_CEILING = 0.25
|
|
223
|
+
APPROVAL_RESUME_RATE_FLOOR = 0.5
|
|
224
|
+
MIN_SAMPLES = 10
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _alert(
|
|
228
|
+
key: str, severity: str, ko: str, en: str, **detail: Any
|
|
229
|
+
) -> Dict[str, Any]:
|
|
230
|
+
return {"key": key, "severity": severity, "ko": ko, "en": en, **detail}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def funnel_alerts(
|
|
234
|
+
counters: Dict[str, int], rates: Dict[str, Optional[float]]
|
|
235
|
+
) -> list:
|
|
236
|
+
"""Turn funnel rates into named signals a person can act on.
|
|
237
|
+
|
|
238
|
+
Pure function of the snapshot — no I/O, no clock — so the thresholds are
|
|
239
|
+
testable and the same numbers always produce the same alerts. Rules stay
|
|
240
|
+
silent below :data:`MIN_SAMPLES`: an alert nobody can trust is worse than
|
|
241
|
+
no alert.
|
|
242
|
+
"""
|
|
243
|
+
alerts: list = []
|
|
244
|
+
file_requests = int(counters.get("file_requests") or 0)
|
|
245
|
+
agent_runs = int(counters.get("agent_runs") or 0)
|
|
246
|
+
approval_pauses = int(counters.get("approval_pauses") or 0)
|
|
247
|
+
|
|
248
|
+
real_file_rate = rates.get("real_file_rate")
|
|
249
|
+
if file_requests >= MIN_SAMPLES and real_file_rate is not None:
|
|
250
|
+
if real_file_rate < REAL_FILE_RATE_FLOOR:
|
|
251
|
+
alerts.append(_alert(
|
|
252
|
+
"real_file_rate_low", "warning",
|
|
253
|
+
f"파일 요청 중 실제 파일이 나온 비율이 {real_file_rate:.0%}입니다 "
|
|
254
|
+
f"(목표 {REAL_FILE_RATE_FLOOR:.0%}). 파일 생성 파이프라인을 확인하세요.",
|
|
255
|
+
f"Only {real_file_rate:.0%} of file requests produced a real file "
|
|
256
|
+
f"(target {REAL_FILE_RATE_FLOOR:.0%}). Check the file-generation pipeline.",
|
|
257
|
+
value=real_file_rate, threshold=REAL_FILE_RATE_FLOOR, samples=file_requests,
|
|
258
|
+
))
|
|
259
|
+
|
|
260
|
+
code_only_rate = rates.get("code_only_rate")
|
|
261
|
+
if file_requests >= MIN_SAMPLES and code_only_rate is not None:
|
|
262
|
+
if code_only_rate > CODE_ONLY_RATE_CEILING:
|
|
263
|
+
alerts.append(_alert(
|
|
264
|
+
"code_only_rate_high", "warning",
|
|
265
|
+
f"파일을 요청했는데 코드/설명만 돌아온 비율이 {code_only_rate:.0%}입니다.",
|
|
266
|
+
f"{code_only_rate:.0%} of file requests came back as code or prose only.",
|
|
267
|
+
value=code_only_rate, threshold=CODE_ONLY_RATE_CEILING, samples=file_requests,
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
needs_review_rate = rates.get("needs_review_rate")
|
|
271
|
+
if agent_runs >= MIN_SAMPLES and needs_review_rate is not None:
|
|
272
|
+
if needs_review_rate > NEEDS_REVIEW_RATE_CEILING:
|
|
273
|
+
alerts.append(_alert(
|
|
274
|
+
"needs_review_rate_high", "warning",
|
|
275
|
+
f"에이전트 실행의 {needs_review_rate:.0%}가 '검토 필요'로 끝났습니다. "
|
|
276
|
+
"더 큰 모델을 쓰거나 요청을 작게 나누세요.",
|
|
277
|
+
f"{needs_review_rate:.0%} of agent runs ended as NEEDS_REVIEW. "
|
|
278
|
+
"Use a larger model or split requests into smaller steps.",
|
|
279
|
+
value=needs_review_rate, threshold=NEEDS_REVIEW_RATE_CEILING, samples=agent_runs,
|
|
280
|
+
))
|
|
281
|
+
|
|
282
|
+
resume_rate = rates.get("approval_resume_rate")
|
|
283
|
+
if approval_pauses >= MIN_SAMPLES and resume_rate is not None:
|
|
284
|
+
if resume_rate < APPROVAL_RESUME_RATE_FLOOR:
|
|
285
|
+
alerts.append(_alert(
|
|
286
|
+
"approval_resume_rate_low", "info",
|
|
287
|
+
f"승인 대기 중 실제로 이어서 실행된 비율이 {resume_rate:.0%}입니다. "
|
|
288
|
+
"승인 카드가 잘 보이는지 확인하세요.",
|
|
289
|
+
f"Only {resume_rate:.0%} of paused runs were resumed. "
|
|
290
|
+
"Check that the approval card is actually reaching users.",
|
|
291
|
+
value=resume_rate, threshold=APPROVAL_RESUME_RATE_FLOOR, samples=approval_pauses,
|
|
292
|
+
))
|
|
293
|
+
|
|
294
|
+
if int(counters.get("ingest_completions") or 0) > 0 and not counters.get("recall_successes"):
|
|
295
|
+
alerts.append(_alert(
|
|
296
|
+
"no_grounded_recall", "warning",
|
|
297
|
+
"자료는 들어왔지만 근거 있는 회상이 아직 한 번도 없었습니다. 검색/인덱싱을 확인하세요.",
|
|
298
|
+
"Content was ingested but no answer has ever been grounded in it yet — "
|
|
299
|
+
"check retrieval and indexing.",
|
|
300
|
+
samples=int(counters.get("ingest_completions") or 0),
|
|
301
|
+
))
|
|
302
|
+
return alerts
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__all__ = ["FunnelMetricsService", "COUNTER_NAMES", "funnel_alerts"]
|