ltcai 9.9.5 → 10.0.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 +39 -37
- package/docs/CHANGELOG.md +234 -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 +38 -8
- 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/retrieval_vector.py +10 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- 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 +119 -2
- package/latticeai/api/chat_contracts.py +9 -0
- 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 +381 -43
- 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 +5 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +188 -0
- 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 +73 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -4
- 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/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-BtCREeN1.js +1 -0
- package/static/app/assets/AdminConsole-TPeeN18T.js +1 -0
- package/static/app/assets/Brain-BKs6JAp0.js +321 -0
- package/static/app/assets/BrainHome-BPGOvSd6.js +2 -0
- package/static/app/assets/BrainSignals-CtzQZ15J.js +1 -0
- package/static/app/assets/Capture-1_NaHWqB.js +1 -0
- package/static/app/assets/CommandPalette-pqvQOXe4.js +1 -0
- package/static/app/assets/Library-DhvoPvC7.js +1 -0
- package/static/app/assets/{LivingBrain-BVWk58MW.js → LivingBrain-DlQ20Q75.js} +1 -1
- package/static/app/assets/ProductFlow-BZvGDRi_.js +1 -0
- package/static/app/assets/ReviewCard-BWgI0D2s.js +3 -0
- package/static/app/assets/System-CSMdYLMy.js +1 -0
- package/static/app/assets/activity-Dlfk8YC7.js +1 -0
- package/static/app/assets/{bot-VmP0kkeA.js → bot-CDvUB76P.js} +1 -1
- package/static/app/assets/brain-xczrohrt.js +1 -0
- package/static/app/assets/button-SOdH3Oyf.js +1 -0
- package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause-CG1ythH4.js} +1 -1
- package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-HXwvjS6W.js} +1 -1
- package/static/app/assets/{cpu-CJhjRtNq.js → cpu-B0d-rGyk.js} +1 -1
- package/static/app/assets/download-BGIkTQL6.js +1 -0
- package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-Dst_Z0_K.js} +1 -1
- package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-D53MsWkV.js} +1 -1
- package/static/app/assets/index-C_IrlQMV.js +10 -0
- package/static/app/assets/index-FxDusbr0.css +2 -0
- package/static/app/assets/{input-CSi6OBJ9.js → input-C5m0riF6.js} +1 -1
- package/static/app/assets/{network-Ck1nqc-v.js → network-C5a-E5iS.js} +1 -1
- package/static/app/assets/primitives-vNXYf58F.js +1 -0
- package/static/app/assets/search-DhbSgW6m.js +1 -0
- package/static/app/assets/shield-alert-Cc-WVXqN.js +1 -0
- package/static/app/assets/{textarea-C4wM0D-Z.js → textarea-BkZ0EqVO.js} +1 -1
- package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-CTtKbAOU.js} +1 -1
- package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-Dx1fi4Wu.js} +1 -1
- package/static/app/assets/users-BFpQXtEF.js +1 -0
- package/static/app/assets/utils-BA_lmW3J.js +7 -0
- package/static/app/assets/workspace-DBPB0jkX.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmxfbqBp.js +0 -1
- package/static/app/assets/Brain-DK_zKCda.js +0 -321
- package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
- package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
- package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
- package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
- package/static/app/assets/Library-4As9VUvy.js +0 -1
- package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
- package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
- package/static/app/assets/System-DSR2zKWd.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-CEu0Wqjl.js +0 -10
- package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
- package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
- package/static/app/assets/utils-BqoznzGP.js +0 -7
|
@@ -42,10 +42,9 @@ from __future__ import annotations
|
|
|
42
42
|
import fnmatch
|
|
43
43
|
import hashlib
|
|
44
44
|
import os
|
|
45
|
-
import threading
|
|
46
45
|
from dataclasses import dataclass, field
|
|
47
46
|
from pathlib import Path
|
|
48
|
-
from typing import Any, Dict, Iterable, List, Optional,
|
|
47
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
49
48
|
|
|
50
49
|
from .runtime.hooks import dispatch_tool
|
|
51
50
|
from .utils import utc_now_iso
|
|
@@ -353,110 +352,14 @@ def _matches_ignore(
|
|
|
353
352
|
return False
|
|
354
353
|
|
|
355
354
|
|
|
356
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
``done_indices`` tracks per-item completion so an interrupted or partially
|
|
365
|
-
failed job can be *resumed* from the remaining items instead of restarting.
|
|
366
|
-
``errors`` is capped at ``max_errors`` records; ``failed`` keeps counting.
|
|
367
|
-
"""
|
|
368
|
-
job_id: str
|
|
369
|
-
items: List[IngestionItem]
|
|
370
|
-
status: str = "queued" # queued | running | completed | failed | partial
|
|
371
|
-
created_at: str = field(default_factory=utc_now_iso)
|
|
372
|
-
updated_at: str = field(default_factory=utc_now_iso)
|
|
373
|
-
processed: int = 0
|
|
374
|
-
failed: int = 0
|
|
375
|
-
total: int = 0
|
|
376
|
-
errors: List[Dict[str, Any]] = field(default_factory=list)
|
|
377
|
-
incremental: bool = True
|
|
378
|
-
user_email: Optional[str] = None
|
|
379
|
-
max_errors: int = JOB_ERRORS_CAP
|
|
380
|
-
done_indices: Set[int] = field(default_factory=set)
|
|
381
|
-
|
|
382
|
-
def touch(self) -> None:
|
|
383
|
-
self.updated_at = utc_now_iso()
|
|
384
|
-
|
|
385
|
-
def record_error(self, index: int, item: IngestionItem, detail: Any) -> None:
|
|
386
|
-
self.failed += 1
|
|
387
|
-
if len(self.errors) < self.max_errors:
|
|
388
|
-
self.errors.append({
|
|
389
|
-
"index": index,
|
|
390
|
-
"source": item.source_uri or item.path or item.title or item.source_type,
|
|
391
|
-
"detail": str(detail)[:500],
|
|
392
|
-
})
|
|
393
|
-
|
|
394
|
-
def remaining_indices(self) -> List[int]:
|
|
395
|
-
return [i for i in range(len(self.items)) if i not in self.done_indices]
|
|
396
|
-
|
|
397
|
-
def as_dict(self) -> Dict[str, Any]:
|
|
398
|
-
"""Frozen job schema consumed by ``/api/ingestion/jobs*``."""
|
|
399
|
-
return {
|
|
400
|
-
"job_id": self.job_id,
|
|
401
|
-
"status": self.status,
|
|
402
|
-
"total": self.total,
|
|
403
|
-
"processed": self.processed,
|
|
404
|
-
"failed": self.failed,
|
|
405
|
-
"errors": list(self.errors),
|
|
406
|
-
"created_at": self.created_at,
|
|
407
|
-
"updated_at": self.updated_at,
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
class BackgroundIngestionQueue:
|
|
412
|
-
"""Simple in-memory queue for background incremental ingestion.
|
|
413
|
-
|
|
414
|
-
For large corpus: this is the seam where a real scheduler / worker pool
|
|
415
|
-
(celery, rq, or internal thread) can be plugged later without changing callers.
|
|
416
|
-
Supports incremental (skip duplicates) vs force reindex.
|
|
417
|
-
"""
|
|
418
|
-
def __init__(self) -> None:
|
|
419
|
-
self._jobs: Dict[str, BackgroundIngestionJob] = {}
|
|
420
|
-
self._counter = 0
|
|
421
|
-
self._lock = threading.Lock()
|
|
422
|
-
|
|
423
|
-
def schedule(
|
|
424
|
-
self,
|
|
425
|
-
items: List[IngestionItem],
|
|
426
|
-
*,
|
|
427
|
-
incremental: bool = True,
|
|
428
|
-
user_email: Optional[str] = None,
|
|
429
|
-
) -> BackgroundIngestionJob:
|
|
430
|
-
with self._lock:
|
|
431
|
-
self._counter += 1
|
|
432
|
-
job_id = f"bg_ingest_{self._counter:04d}"
|
|
433
|
-
job = BackgroundIngestionJob(
|
|
434
|
-
job_id=job_id,
|
|
435
|
-
items=items,
|
|
436
|
-
total=len(items),
|
|
437
|
-
incremental=incremental,
|
|
438
|
-
user_email=user_email,
|
|
439
|
-
)
|
|
440
|
-
# annotate items for downstream
|
|
441
|
-
for it in job.items:
|
|
442
|
-
# attach flag without breaking dataclass defaults (use metadata)
|
|
443
|
-
it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
|
|
444
|
-
with self._lock:
|
|
445
|
-
self._jobs[job_id] = job
|
|
446
|
-
return job
|
|
447
|
-
|
|
448
|
-
def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
449
|
-
return self._jobs.get(job_id)
|
|
450
|
-
|
|
451
|
-
def list_pending(self) -> List[BackgroundIngestionJob]:
|
|
452
|
-
return [j for j in self._jobs.values() if j.status == "queued"]
|
|
453
|
-
|
|
454
|
-
def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
|
|
455
|
-
"""Most recent jobs first (insertion order is schedule order)."""
|
|
456
|
-
limit = max(1, int(limit))
|
|
457
|
-
with self._lock:
|
|
458
|
-
jobs = list(self._jobs.values())
|
|
459
|
-
return list(reversed(jobs))[:limit]
|
|
355
|
+
# Background job scheduling + progress lives in its own module (v9.9.6):
|
|
356
|
+
# the pipeline owns "ingest one item", the queue owns "schedule many and
|
|
357
|
+
# report progress". Re-exported so every existing import keeps working.
|
|
358
|
+
from .ingestion_jobs import ( # noqa: E402,F401 — re-export for existing importers
|
|
359
|
+
JOB_ERRORS_CAP,
|
|
360
|
+
BackgroundIngestionJob,
|
|
361
|
+
BackgroundIngestionQueue,
|
|
362
|
+
)
|
|
460
363
|
|
|
461
364
|
|
|
462
365
|
@dataclass
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Background ingestion jobs — queue + per-job progress (v9.9.6 extraction).
|
|
2
|
+
|
|
3
|
+
Split out of :mod:`lattice_brain.ingestion` (behaviour-preserving move, review
|
|
4
|
+
2026-07-27 P2 #8 "ingestion job/watch 분리"). The pipeline owns *what it means
|
|
5
|
+
to ingest one item*; this module owns *scheduling many of them and reporting
|
|
6
|
+
progress* — a genuinely separate concern with its own frozen wire schema
|
|
7
|
+
(``/api/ingestion/jobs*``) and its own resume semantics.
|
|
8
|
+
|
|
9
|
+
The seam is also where a real scheduler (thread pool, rq, celery) would plug
|
|
10
|
+
in without touching the pipeline.
|
|
11
|
+
|
|
12
|
+
``IngestionItem`` is imported only for type checking: the pipeline module owns
|
|
13
|
+
that dataclass, and a runtime import here would be circular.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
|
|
21
|
+
|
|
22
|
+
from .utils import utc_now_iso
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
25
|
+
from .ingestion import IngestionItem
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
JOB_ERRORS_CAP = 50 # per-job error records kept (failed count keeps counting)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class BackgroundIngestionJob:
|
|
33
|
+
"""Job descriptor + progress state for background/incremental indexing.
|
|
34
|
+
|
|
35
|
+
``done_indices`` tracks per-item completion so an interrupted or partially
|
|
36
|
+
failed job can be *resumed* from the remaining items instead of restarting.
|
|
37
|
+
``errors`` is capped at ``max_errors`` records; ``failed`` keeps counting.
|
|
38
|
+
"""
|
|
39
|
+
job_id: str
|
|
40
|
+
items: List[IngestionItem]
|
|
41
|
+
status: str = "queued" # queued | running | completed | failed | partial
|
|
42
|
+
created_at: str = field(default_factory=utc_now_iso)
|
|
43
|
+
updated_at: str = field(default_factory=utc_now_iso)
|
|
44
|
+
processed: int = 0
|
|
45
|
+
failed: int = 0
|
|
46
|
+
total: int = 0
|
|
47
|
+
errors: List[Dict[str, Any]] = field(default_factory=list)
|
|
48
|
+
incremental: bool = True
|
|
49
|
+
user_email: Optional[str] = None
|
|
50
|
+
max_errors: int = JOB_ERRORS_CAP
|
|
51
|
+
done_indices: Set[int] = field(default_factory=set)
|
|
52
|
+
|
|
53
|
+
def touch(self) -> None:
|
|
54
|
+
self.updated_at = utc_now_iso()
|
|
55
|
+
|
|
56
|
+
def record_error(self, index: int, item: IngestionItem, detail: Any) -> None:
|
|
57
|
+
self.failed += 1
|
|
58
|
+
if len(self.errors) < self.max_errors:
|
|
59
|
+
self.errors.append({
|
|
60
|
+
"index": index,
|
|
61
|
+
"source": item.source_uri or item.path or item.title or item.source_type,
|
|
62
|
+
"detail": str(detail)[:500],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
def remaining_indices(self) -> List[int]:
|
|
66
|
+
return [i for i in range(len(self.items)) if i not in self.done_indices]
|
|
67
|
+
|
|
68
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
69
|
+
"""Frozen job schema consumed by ``/api/ingestion/jobs*``."""
|
|
70
|
+
return {
|
|
71
|
+
"job_id": self.job_id,
|
|
72
|
+
"status": self.status,
|
|
73
|
+
"total": self.total,
|
|
74
|
+
"processed": self.processed,
|
|
75
|
+
"failed": self.failed,
|
|
76
|
+
"errors": list(self.errors),
|
|
77
|
+
"created_at": self.created_at,
|
|
78
|
+
"updated_at": self.updated_at,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BackgroundIngestionQueue:
|
|
83
|
+
"""Simple in-memory queue for background incremental ingestion.
|
|
84
|
+
|
|
85
|
+
For large corpus: this is the seam where a real scheduler / worker pool
|
|
86
|
+
(celery, rq, or internal thread) can be plugged later without changing callers.
|
|
87
|
+
Supports incremental (skip duplicates) vs force reindex.
|
|
88
|
+
"""
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self._jobs: Dict[str, BackgroundIngestionJob] = {}
|
|
91
|
+
self._counter = 0
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
|
|
94
|
+
def schedule(
|
|
95
|
+
self,
|
|
96
|
+
items: List[IngestionItem],
|
|
97
|
+
*,
|
|
98
|
+
incremental: bool = True,
|
|
99
|
+
user_email: Optional[str] = None,
|
|
100
|
+
) -> BackgroundIngestionJob:
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._counter += 1
|
|
103
|
+
job_id = f"bg_ingest_{self._counter:04d}"
|
|
104
|
+
job = BackgroundIngestionJob(
|
|
105
|
+
job_id=job_id,
|
|
106
|
+
items=items,
|
|
107
|
+
total=len(items),
|
|
108
|
+
incremental=incremental,
|
|
109
|
+
user_email=user_email,
|
|
110
|
+
)
|
|
111
|
+
# annotate items for downstream
|
|
112
|
+
for it in job.items:
|
|
113
|
+
# attach flag without breaking dataclass defaults (use metadata)
|
|
114
|
+
it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
|
|
115
|
+
with self._lock:
|
|
116
|
+
self._jobs[job_id] = job
|
|
117
|
+
return job
|
|
118
|
+
|
|
119
|
+
def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
120
|
+
return self._jobs.get(job_id)
|
|
121
|
+
|
|
122
|
+
def list_pending(self) -> List[BackgroundIngestionJob]:
|
|
123
|
+
return [j for j in self._jobs.values() if j.status == "queued"]
|
|
124
|
+
|
|
125
|
+
def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
|
|
126
|
+
"""Most recent jobs first (insertion order is schedule order)."""
|
|
127
|
+
limit = max(1, int(limit))
|
|
128
|
+
with self._lock:
|
|
129
|
+
jobs = list(self._jobs.values())
|
|
130
|
+
return list(reversed(jobs))[:limit]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = ["JOB_ERRORS_CAP", "BackgroundIngestionJob", "BackgroundIngestionQueue"]
|
|
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
|
|
|
48
48
|
from ..utils import now_iso as _now
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
MULTI_AGENT_VERSION = "
|
|
51
|
+
MULTI_AGENT_VERSION = "10.0.0"
|
|
52
52
|
|
|
53
53
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
54
54
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
|
@@ -57,6 +57,14 @@ def create_brain_intelligence_router(
|
|
|
57
57
|
scope = gate_read(request)
|
|
58
58
|
return service.contradictions(user_email=user, workspace_id=scope)
|
|
59
59
|
|
|
60
|
+
@router.get("/api/brain/garden")
|
|
61
|
+
async def brain_garden(request: Request, limit: int = 8):
|
|
62
|
+
"""Knowledge garden overview (v9.9.7): recent / contradictions /
|
|
63
|
+
stale / frequent, read-only and workspace-scoped."""
|
|
64
|
+
user = require_user(request)
|
|
65
|
+
scope = gate_read(request)
|
|
66
|
+
return service.garden_overview(user_email=user, workspace_id=scope, limit=limit)
|
|
67
|
+
|
|
60
68
|
@router.get("/api/brain/vector-freshness")
|
|
61
69
|
async def brain_vector_freshness(request: Request):
|
|
62
70
|
"""Vector index freshness summary (read-only, never raises).
|
package/latticeai/api/chat.py
CHANGED
|
@@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
16
16
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
17
17
|
|
|
18
18
|
from latticeai.api.chat_agent_http import AgentHTTPController
|
|
19
|
+
from latticeai.core.project_sessions import ProjectSessionStore
|
|
19
20
|
from latticeai.api.chat_contracts import (
|
|
20
21
|
AgentEvalRequest,
|
|
21
22
|
AgentRequest,
|
|
@@ -197,6 +198,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
197
198
|
if context.data_dir
|
|
198
199
|
else None
|
|
199
200
|
),
|
|
201
|
+
# Multi-turn project loop (v9.9.6): optional — without a data dir the
|
|
202
|
+
# agent runs exactly as before, one run at a time with no project state.
|
|
203
|
+
project_sessions=(
|
|
204
|
+
ProjectSessionStore(Path(context.data_dir) / "project_sessions")
|
|
205
|
+
if context.data_dir
|
|
206
|
+
else None
|
|
207
|
+
),
|
|
208
|
+
artifact_ledger=context.artifact_ledger,
|
|
200
209
|
)
|
|
201
210
|
intent_controller = ChatIntentController(
|
|
202
211
|
model_router=model_router,
|
|
@@ -14,11 +14,14 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Any, Dict, Optional
|
|
15
15
|
|
|
16
16
|
from fastapi import APIRouter, HTTPException, Request
|
|
17
|
+
from fastapi.responses import StreamingResponse
|
|
17
18
|
|
|
18
19
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
19
20
|
from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
|
|
20
21
|
from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
|
|
22
|
+
from latticeai.api.chat_stream import agent_live_stream
|
|
21
23
|
from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
|
|
24
|
+
from latticeai.core.run_explain import explain_run
|
|
22
25
|
from latticeai.core.run_store import (
|
|
23
26
|
AgentRunStore,
|
|
24
27
|
hash_approval_token,
|
|
@@ -48,6 +51,8 @@ class AgentHTTPController:
|
|
|
48
51
|
ensure_agent_root: Any,
|
|
49
52
|
funnel_metrics: Any = None,
|
|
50
53
|
run_store: Any = None,
|
|
54
|
+
project_sessions: Any = None,
|
|
55
|
+
artifact_ledger: Any = None,
|
|
51
56
|
) -> None:
|
|
52
57
|
self.runtime = runtime
|
|
53
58
|
self.model_router = model_router
|
|
@@ -65,6 +70,12 @@ class AgentHTTPController:
|
|
|
65
70
|
self.agent_root = Path(agent_root)
|
|
66
71
|
self.ensure_agent_root = ensure_agent_root
|
|
67
72
|
self.funnel_metrics = funnel_metrics
|
|
73
|
+
# Multi-turn project loop (v9.9.6). Optional: without a store the
|
|
74
|
+
# agent behaves exactly as before — single runs, no project memory.
|
|
75
|
+
self.project_sessions = project_sessions
|
|
76
|
+
# Re-search loop (v9.9.6): the next turn must see files this run just
|
|
77
|
+
# wrote, even before asynchronous indexing catches up.
|
|
78
|
+
self.artifact_ledger = artifact_ledger
|
|
68
79
|
self._pending_lock = threading.Lock()
|
|
69
80
|
# awaiting_approval runs: run_id -> paused run state + approval token.
|
|
70
81
|
# Fail-closed: governed steps only ever execute after resume presents
|
|
@@ -91,6 +102,24 @@ class AgentHTTPController:
|
|
|
91
102
|
|
|
92
103
|
@router.post("/agent")
|
|
93
104
|
async def agent(req: AgentRequest, request: Request):
|
|
105
|
+
if req.stream:
|
|
106
|
+
# Surface parity (v9.9.7): the web app already watches the loop
|
|
107
|
+
# live through the chat route. `/agent` clients (VS Code) get
|
|
108
|
+
# the same named `agent_step` frames here, followed by the
|
|
109
|
+
# exact terminal payload the JSON response returns — a client
|
|
110
|
+
# that ignores named events sees the historical shape.
|
|
111
|
+
async def start(observer):
|
|
112
|
+
return await self.agent(req, request, on_step=observer)
|
|
113
|
+
|
|
114
|
+
return StreamingResponse(
|
|
115
|
+
agent_live_stream(
|
|
116
|
+
start,
|
|
117
|
+
router=self.model_router,
|
|
118
|
+
model_id=req.executing_model,
|
|
119
|
+
),
|
|
120
|
+
media_type="text/event-stream",
|
|
121
|
+
headers={"X-Routed-To": "agent"},
|
|
122
|
+
)
|
|
94
123
|
return await self.agent(req, request)
|
|
95
124
|
|
|
96
125
|
@router.post("/agent/resume")
|
|
@@ -238,6 +267,18 @@ class AgentHTTPController:
|
|
|
238
267
|
ctx.on_step = on_step
|
|
239
268
|
ctx.executing_model = req.executing_model
|
|
240
269
|
ctx.reviewing_model = req.reviewing_model
|
|
270
|
+
ctx.project_context = self._project_summary(req, current_user)
|
|
271
|
+
# Autonomy dial (v9.9.8): resolve the user/workspace-scoped mode once so
|
|
272
|
+
# the plan gate and every per-tool gate in this run agree, even if the
|
|
273
|
+
# stored preference changes mid-run.
|
|
274
|
+
mode_probe = getattr(self.runtime, "resolve_permission_mode", None)
|
|
275
|
+
if callable(mode_probe):
|
|
276
|
+
try:
|
|
277
|
+
ctx.permission_mode = mode_probe(
|
|
278
|
+
user_email=current_user, workspace_id=req.workspace_id,
|
|
279
|
+
).value
|
|
280
|
+
except Exception: # noqa: BLE001 — never fail a run over the dial
|
|
281
|
+
ctx.permission_mode = None
|
|
241
282
|
ctx.state = AgentState.PLANNING
|
|
242
283
|
ctx.state_history.append(ctx.state.value)
|
|
243
284
|
await self.runtime.plan(
|
|
@@ -288,6 +329,55 @@ class AgentHTTPController:
|
|
|
288
329
|
max_retry,
|
|
289
330
|
)
|
|
290
331
|
|
|
332
|
+
def _project_summary(self, req: AgentRequest, current_user: str) -> str:
|
|
333
|
+
"""Where this project stands, for the planner/executor prompt.
|
|
334
|
+
|
|
335
|
+
Advisory: a missing store, an unknown project id, or a read failure
|
|
336
|
+
yields "" and the run proceeds as a standalone run.
|
|
337
|
+
"""
|
|
338
|
+
project_id = getattr(req, "project_id", None)
|
|
339
|
+
if not project_id or self.project_sessions is None:
|
|
340
|
+
return ""
|
|
341
|
+
try:
|
|
342
|
+
return self.project_sessions.summary(
|
|
343
|
+
str(project_id),
|
|
344
|
+
user_email=current_user or None,
|
|
345
|
+
workspace_id=req.workspace_id,
|
|
346
|
+
)
|
|
347
|
+
except Exception as exc: # noqa: BLE001 — project context never gates a run
|
|
348
|
+
logging.warning("project session summary failed: %s", exc)
|
|
349
|
+
return ""
|
|
350
|
+
|
|
351
|
+
def _record_project_run(
|
|
352
|
+
self,
|
|
353
|
+
req: AgentRequest,
|
|
354
|
+
current_user: str,
|
|
355
|
+
ctx: AgentRunContext,
|
|
356
|
+
payload: Dict[str, Any],
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Fold a finished run into its project session (best effort).
|
|
359
|
+
|
|
360
|
+
The verification result is stored exactly as the run reported it — a
|
|
361
|
+
NEEDS_REVIEW run never becomes a project's "done".
|
|
362
|
+
"""
|
|
363
|
+
project_id = getattr(req, "project_id", None)
|
|
364
|
+
if not project_id or self.project_sessions is None:
|
|
365
|
+
return
|
|
366
|
+
try:
|
|
367
|
+
self.project_sessions.record_run(
|
|
368
|
+
str(project_id),
|
|
369
|
+
run_id=str(payload.get("run_id") or ""),
|
|
370
|
+
goal=str(ctx.plan.get("goal") or req.message),
|
|
371
|
+
status=str(payload.get("status") or ""),
|
|
372
|
+
final_state=ctx.state.value,
|
|
373
|
+
files=payload.get("created_files") or [],
|
|
374
|
+
explanation=payload.get("explanation"),
|
|
375
|
+
user_email=current_user or None,
|
|
376
|
+
workspace_id=req.workspace_id,
|
|
377
|
+
)
|
|
378
|
+
except Exception as exc: # noqa: BLE001 — bookkeeping never fails a run
|
|
379
|
+
logging.warning("project session record failed: %s", exc)
|
|
380
|
+
|
|
291
381
|
def _pause_for_approval(
|
|
292
382
|
self,
|
|
293
383
|
ctx: AgentRunContext,
|
|
@@ -541,7 +631,8 @@ class AgentHTTPController:
|
|
|
541
631
|
self.funnel_metrics.increment("needs_review_runs")
|
|
542
632
|
except Exception as exc: # noqa: BLE001
|
|
543
633
|
logging.warning("funnel metrics increment failed: %s", exc)
|
|
544
|
-
|
|
634
|
+
loop_summary = ctx.trace.summary()
|
|
635
|
+
payload = {
|
|
545
636
|
"status": "ok" if ctx.state == AgentState.DONE else "failed",
|
|
546
637
|
"response": message,
|
|
547
638
|
"workspace": str(self.agent_root),
|
|
@@ -550,8 +641,34 @@ class AgentHTTPController:
|
|
|
550
641
|
"final_state": ctx.state.value,
|
|
551
642
|
"created_files": collect_created_files(ctx.transcript),
|
|
552
643
|
"artifacts": collect_artifacts(ctx.transcript),
|
|
553
|
-
"loop":
|
|
644
|
+
"loop": loop_summary,
|
|
645
|
+
# Plain-language outcome (v9.9.6): the loop counters say a weak
|
|
646
|
+
# model needed three repairs; this says it in a sentence a person
|
|
647
|
+
# can read, and never upgrades a non-success into a success.
|
|
648
|
+
"explanation": explain_run(
|
|
649
|
+
state=ctx.state,
|
|
650
|
+
loop=loop_summary,
|
|
651
|
+
transcript=ctx.transcript,
|
|
652
|
+
max_retry=max_retry,
|
|
653
|
+
),
|
|
554
654
|
}
|
|
655
|
+
# Multi-turn project loop (v9.9.6): the project remembers this run's
|
|
656
|
+
# files and its honest verdict, so the next turn continues instead of
|
|
657
|
+
# restarting.
|
|
658
|
+
self._record_project_run(req, current_user, ctx, payload)
|
|
659
|
+
if self.artifact_ledger is not None and payload.get("created_files"):
|
|
660
|
+
try:
|
|
661
|
+
self.artifact_ledger.record(
|
|
662
|
+
payload["created_files"],
|
|
663
|
+
user_email=current_user or None,
|
|
664
|
+
conversation_id=req.conversation_id,
|
|
665
|
+
workspace_id=req.workspace_id,
|
|
666
|
+
)
|
|
667
|
+
except Exception as exc: # noqa: BLE001 — advisory context only
|
|
668
|
+
logging.warning("artifact ledger record failed: %s", exc)
|
|
669
|
+
if getattr(req, "project_id", None):
|
|
670
|
+
payload["project_id"] = str(req.project_id)
|
|
671
|
+
return payload
|
|
555
672
|
|
|
556
673
|
async def resume(
|
|
557
674
|
self,
|
|
@@ -36,6 +36,15 @@ class AgentRequest(BaseModel):
|
|
|
36
36
|
executing_model: Optional[str] = None
|
|
37
37
|
reviewing_model: Optional[str] = None
|
|
38
38
|
human_in_loop: bool = False
|
|
39
|
+
# Multi-turn project loop (v9.9.6): when set, the run reads the project's
|
|
40
|
+
# accumulated state (files, open TODOs, last verification) and folds its
|
|
41
|
+
# own outcome back in when it finishes.
|
|
42
|
+
project_id: Optional[str] = None
|
|
43
|
+
# Live loop visibility (v9.9.7): stream named `agent_step` SSE frames while
|
|
44
|
+
# the run executes, then the same terminal payload as the JSON response.
|
|
45
|
+
# Surface parity — the web app already gets this through the chat route;
|
|
46
|
+
# `/agent` clients (VS Code) need it on their own endpoint.
|
|
47
|
+
stream: bool = False
|
|
39
48
|
|
|
40
49
|
|
|
41
50
|
class AgentResumeRequest(BaseModel):
|
|
@@ -134,6 +134,11 @@ class DocumentGenerationCoordinator:
|
|
|
134
134
|
logging.debug(
|
|
135
135
|
"Document generation context retrieved from knowledge graph."
|
|
136
136
|
)
|
|
137
|
+
# Shared context contract (v9.9.6): document generation now
|
|
138
|
+
# reports the same context_quality signal chat does, so a
|
|
139
|
+
# thin-context document is as visible as a thin-context answer.
|
|
140
|
+
if retrieval.get("stats", {}).get("budget_trimmed"):
|
|
141
|
+
logging.debug("Document context trimmed to the shared budget.")
|
|
137
142
|
except Exception as exc:
|
|
138
143
|
logging.warning("Knowledge graph reinforcement skipped: %s", exc)
|
|
139
144
|
return DocumentPreparation(is_document, context, retrieval)
|
|
@@ -165,6 +170,16 @@ class DocumentGenerationCoordinator:
|
|
|
165
170
|
graph_markdown = (preparation.retrieval or {}).get("context_markdown", "")
|
|
166
171
|
system_prompt = session.get_system_prompt(graph_markdown)
|
|
167
172
|
footnote = format_sources_footnote((preparation.retrieval or {}).get("sources", []))
|
|
173
|
+
# Shared context contract (v9.9.6): the document path records the same
|
|
174
|
+
# context_quality + assembly trace on the answer trace that chat does,
|
|
175
|
+
# so both surfaces answer "how well was this grounded?" identically.
|
|
176
|
+
context_quality = (preparation.retrieval or {}).get("context_quality")
|
|
177
|
+
if isinstance(trace_seed, dict):
|
|
178
|
+
if context_quality:
|
|
179
|
+
trace_seed["context_quality"] = context_quality
|
|
180
|
+
assembly_trace = (preparation.retrieval or {}).get("trace")
|
|
181
|
+
if assembly_trace:
|
|
182
|
+
trace_seed["context_assembly"] = assembly_trace
|
|
168
183
|
|
|
169
184
|
if req.stream:
|
|
170
185
|
async def stream_document():
|
|
@@ -245,6 +260,7 @@ class DocumentGenerationCoordinator:
|
|
|
245
260
|
"response": response_text,
|
|
246
261
|
"trace_id": trace_record["id"],
|
|
247
262
|
"trace": trace_record,
|
|
263
|
+
**({"context_quality": context_quality} if context_quality else {}),
|
|
248
264
|
}
|
|
249
265
|
)
|
|
250
266
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Evidence → action router (v9.9.6).
|
|
2
|
+
|
|
3
|
+
``POST /api/evidence/actions`` turns an answer's citations into ready-to-send,
|
|
4
|
+
evidence-scoped follow-up prompts. Read-only and deterministic: it resolves
|
|
5
|
+
graph nodes and composes text — no model call, no writes. Execution stays on
|
|
6
|
+
the existing chat / file-generation path so there is exactly one road from a
|
|
7
|
+
prompt to an artifact.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, List, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Request
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EvidenceActionsRequest(BaseModel):
|
|
19
|
+
"""Citations from one answer (the ids the grounding badge reported)."""
|
|
20
|
+
|
|
21
|
+
question: str = ""
|
|
22
|
+
source_ids: List[str] = Field(default_factory=list)
|
|
23
|
+
language: str = "ko"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_evidence_actions_router(
|
|
27
|
+
*,
|
|
28
|
+
service: Any,
|
|
29
|
+
require_user: Callable[[Request], Any],
|
|
30
|
+
allowed_workspaces_for: Optional[Callable[[Any], Any]] = None,
|
|
31
|
+
) -> APIRouter:
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
|
|
34
|
+
@router.post("/api/evidence/actions")
|
|
35
|
+
async def evidence_actions(req: EvidenceActionsRequest, request: Request):
|
|
36
|
+
user = require_user(request)
|
|
37
|
+
scope = allowed_workspaces_for(user) if allowed_workspaces_for is not None else None
|
|
38
|
+
return service.actions_for(
|
|
39
|
+
question=req.question,
|
|
40
|
+
source_ids=req.source_ids,
|
|
41
|
+
language=req.language,
|
|
42
|
+
allowed_workspaces=scope,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return router
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["create_evidence_actions_router", "EvidenceActionsRequest"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Permission mode API — strict / trusted / bypass dial (v9.9.8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from latticeai.core.permission_mode import mode_catalog, normalize_mode
|
|
11
|
+
from latticeai.services.permission_mode_service import PermissionModeService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SetPermissionModeRequest(BaseModel):
|
|
15
|
+
mode: str = Field(..., description="strict | trusted | bypass")
|
|
16
|
+
workspace_id: Optional[str] = None
|
|
17
|
+
acknowledge_risk: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_permission_mode_router(
|
|
21
|
+
*,
|
|
22
|
+
service: PermissionModeService,
|
|
23
|
+
require_user: Callable[..., str],
|
|
24
|
+
) -> APIRouter:
|
|
25
|
+
router = APIRouter(tags=["permission-mode"])
|
|
26
|
+
|
|
27
|
+
@router.get("/api/permission-mode")
|
|
28
|
+
async def get_permission_mode(
|
|
29
|
+
request: Request,
|
|
30
|
+
workspace_id: Optional[str] = None,
|
|
31
|
+
):
|
|
32
|
+
user = require_user(request)
|
|
33
|
+
header_ws = request.headers.get("X-Workspace-Id")
|
|
34
|
+
scope = workspace_id or (header_ws.strip() if header_ws else None)
|
|
35
|
+
return service.get(user_email=user, workspace_id=scope)
|
|
36
|
+
|
|
37
|
+
@router.get("/api/permission-mode/catalog")
|
|
38
|
+
async def permission_mode_catalog(request: Request):
|
|
39
|
+
require_user(request)
|
|
40
|
+
return {"modes": mode_catalog()}
|
|
41
|
+
|
|
42
|
+
@router.post("/api/permission-mode")
|
|
43
|
+
async def set_permission_mode(body: SetPermissionModeRequest, request: Request):
|
|
44
|
+
user = require_user(request)
|
|
45
|
+
header_ws = request.headers.get("X-Workspace-Id")
|
|
46
|
+
scope = body.workspace_id or (header_ws.strip() if header_ws else None)
|
|
47
|
+
try:
|
|
48
|
+
return service.set_mode(
|
|
49
|
+
normalize_mode(body.mode),
|
|
50
|
+
user_email=user,
|
|
51
|
+
workspace_id=scope,
|
|
52
|
+
acknowledge_risk=body.acknowledge_risk,
|
|
53
|
+
source="api",
|
|
54
|
+
)
|
|
55
|
+
except PermissionError as exc:
|
|
56
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
57
|
+
|
|
58
|
+
return router
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["create_permission_mode_router", "SetPermissionModeRequest"]
|