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
|
@@ -36,6 +36,7 @@ STATUS_URL = f"{BASE_URL}/status"
|
|
|
36
36
|
MODELS_URL = f"{BASE_URL}/models"
|
|
37
37
|
GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
|
|
38
38
|
UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
|
|
39
|
+
PROPOSALS_URL = f"{BASE_URL}/api/proposals"
|
|
39
40
|
|
|
40
41
|
AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
|
|
41
42
|
AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
|
|
@@ -297,6 +298,9 @@ MAIN_MENU = {
|
|
|
297
298
|
{"text": "🔗 웹 UI 열기", "callback_data": "cmd:web"},
|
|
298
299
|
{"text": "🔌 MCP 도구 목록", "callback_data": "cmd:mcp"},
|
|
299
300
|
],
|
|
301
|
+
[
|
|
302
|
+
{"text": "🗂 변경 제안 검토", "callback_data": "cmd:review"},
|
|
303
|
+
],
|
|
300
304
|
]
|
|
301
305
|
}
|
|
302
306
|
|
|
@@ -665,6 +669,176 @@ def collect_generated_files(agent_data):
|
|
|
665
669
|
files.append((path, target))
|
|
666
670
|
return files
|
|
667
671
|
|
|
672
|
+
def format_grounding_badge(data, *, language: str = "ko") -> str:
|
|
673
|
+
"""Answer-grounding badge line for a `/chat` reply (v9.9.7).
|
|
674
|
+
|
|
675
|
+
Telegram used to show only the answer text, so a reply the Brain could not
|
|
676
|
+
ground looked identical to one built on real sources. This renders exactly
|
|
677
|
+
the verdict the server issued — and reports an absent verdict as unknown
|
|
678
|
+
rather than promoting it to "근거 있음".
|
|
679
|
+
"""
|
|
680
|
+
if not isinstance(data, dict):
|
|
681
|
+
return ""
|
|
682
|
+
grounding = data.get("grounding")
|
|
683
|
+
if not isinstance(grounding, dict):
|
|
684
|
+
return ""
|
|
685
|
+
status = str(grounding.get("status") or "")
|
|
686
|
+
if not status:
|
|
687
|
+
return ""
|
|
688
|
+
korean = not str(language or "ko").startswith("en")
|
|
689
|
+
cited = [
|
|
690
|
+
str(item.get("title") or "").strip()
|
|
691
|
+
for item in (grounding.get("cited") or [])
|
|
692
|
+
if isinstance(item, dict) and str(item.get("title") or "").strip()
|
|
693
|
+
]
|
|
694
|
+
if status == "supported":
|
|
695
|
+
head = "✅ 근거 있음" if korean else "✅ grounded"
|
|
696
|
+
if cited:
|
|
697
|
+
head += " — " + ", ".join(cited[:3])
|
|
698
|
+
return head
|
|
699
|
+
if status in {"unsupported", "no_context"}:
|
|
700
|
+
head = "⚠️ 근거 없음" if korean else "⚠️ not grounded"
|
|
701
|
+
reason = str(grounding.get("reason") or "").strip()
|
|
702
|
+
return f"{head} — {reason}" if reason else head
|
|
703
|
+
return "❔ 근거 확인 불가" if korean else "❔ grounding unknown"
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
async def send_grounding_badge(client, chat_id, data):
|
|
707
|
+
text = format_grounding_badge(data)
|
|
708
|
+
if text:
|
|
709
|
+
await send_message(client, chat_id, text)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def format_proposals(payload) -> list:
|
|
713
|
+
"""`GET /api/proposals` → ``[(item_id, label)]`` for the Review Center.
|
|
714
|
+
|
|
715
|
+
Rows without an id are dropped: an un-actionable row is worse than none.
|
|
716
|
+
"""
|
|
717
|
+
root = payload if isinstance(payload, dict) else {}
|
|
718
|
+
rows = payload if isinstance(payload, list) else (root.get("items") or [])
|
|
719
|
+
out = []
|
|
720
|
+
for raw in rows if isinstance(rows, list) else []:
|
|
721
|
+
if not isinstance(raw, dict):
|
|
722
|
+
continue
|
|
723
|
+
item_id = str(raw.get("id") or "").strip()
|
|
724
|
+
if not item_id:
|
|
725
|
+
continue
|
|
726
|
+
body = raw.get("payload") if isinstance(raw.get("payload"), dict) else {}
|
|
727
|
+
provenance = raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {}
|
|
728
|
+
path = str(body.get("path") or provenance.get("path") or "")
|
|
729
|
+
title = str(raw.get("title") or path or item_id)
|
|
730
|
+
change_class = str(body.get("change_class") or provenance.get("change_class") or "")
|
|
731
|
+
label = title if not change_class else f"{title} ({change_class})"
|
|
732
|
+
out.append((item_id, label))
|
|
733
|
+
return out
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
async def show_review_center(client, chat_id):
|
|
737
|
+
"""Review Center parity (v9.9.7): staged change proposals with inline
|
|
738
|
+
approve/reject, using the same `/api/proposals` surface as the web app."""
|
|
739
|
+
try:
|
|
740
|
+
async with _server_client() as sc:
|
|
741
|
+
res = await sc.get(PROPOSALS_URL, timeout=30.0)
|
|
742
|
+
if res.status_code != 200:
|
|
743
|
+
await send_message(client, chat_id, f"검토함을 불러오지 못했습니다 ({res.status_code})")
|
|
744
|
+
return
|
|
745
|
+
items = format_proposals(res.json())
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
await send_message(client, chat_id, f"검토함 조회 실패: {exc}")
|
|
748
|
+
return
|
|
749
|
+
if not items:
|
|
750
|
+
await send_message(client, chat_id, "🗂 검토할 변경 제안이 없습니다.")
|
|
751
|
+
return
|
|
752
|
+
lines = ["🗂 변경 제안 (승인해야 적용됩니다)"]
|
|
753
|
+
keyboard = []
|
|
754
|
+
for item_id, label in items[:8]:
|
|
755
|
+
lines.append(f"- {label}")
|
|
756
|
+
keyboard.append([
|
|
757
|
+
{"text": f"✅ 승인 — {label}"[:64], "callback_data": f"proposal:approve:{item_id}"[:64]},
|
|
758
|
+
{"text": "❌ 거절"[:64], "callback_data": f"proposal:reject:{item_id}"[:64]},
|
|
759
|
+
])
|
|
760
|
+
if len(items) > 8:
|
|
761
|
+
lines.append(f"… 외 {len(items) - 8}건")
|
|
762
|
+
await send_message(
|
|
763
|
+
client, chat_id, "\n".join(lines),
|
|
764
|
+
reply_markup={"inline_keyboard": keyboard},
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
async def handle_proposal_callback(client, chat_id, data: str):
|
|
769
|
+
"""Apply or reject one staged proposal.
|
|
770
|
+
|
|
771
|
+
A 409 means the target file changed since staging — nothing was written,
|
|
772
|
+
and the user is told exactly that instead of a silent retry.
|
|
773
|
+
"""
|
|
774
|
+
try:
|
|
775
|
+
_, decision, item_id = data.split(":", 2)
|
|
776
|
+
except ValueError:
|
|
777
|
+
return
|
|
778
|
+
if not item_id:
|
|
779
|
+
return
|
|
780
|
+
suffix = "approve" if decision == "approve" else "reject"
|
|
781
|
+
url = f"{PROPOSALS_URL}/{item_id}/{suffix}"
|
|
782
|
+
try:
|
|
783
|
+
async with _server_client() as sc:
|
|
784
|
+
res = await sc.post(url, json={}, timeout=120.0)
|
|
785
|
+
except Exception as exc:
|
|
786
|
+
await send_message(client, chat_id, f"❌ 처리 실패: {exc}")
|
|
787
|
+
return
|
|
788
|
+
if res.status_code == 409:
|
|
789
|
+
await send_message(
|
|
790
|
+
client, chat_id,
|
|
791
|
+
"⚠️ 제안을 만든 뒤 파일이 바뀌어서 적용하지 않았습니다. 아무것도 쓰지 않았습니다.",
|
|
792
|
+
)
|
|
793
|
+
return
|
|
794
|
+
if res.status_code != 200:
|
|
795
|
+
await send_message(client, chat_id, f"❌ 서버 에러 ({res.status_code})")
|
|
796
|
+
return
|
|
797
|
+
if suffix == "approve":
|
|
798
|
+
try:
|
|
799
|
+
applied = res.json().get("path") or item_id
|
|
800
|
+
except Exception:
|
|
801
|
+
applied = item_id
|
|
802
|
+
await send_message(client, chat_id, f"✅ 적용했습니다: {applied}")
|
|
803
|
+
else:
|
|
804
|
+
await send_message(client, chat_id, "🚫 제안을 거절했습니다.")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def format_run_explanation(agent_data, *, language: str = "ko") -> str:
|
|
808
|
+
"""Plain-language outcome line for a finished agent run (v9.9.6).
|
|
809
|
+
|
|
810
|
+
Telegram used to show only the final message, so a NEEDS_REVIEW run read
|
|
811
|
+
exactly like a success. The server already computes the honest sentence
|
|
812
|
+
(`explanation`); this renders it. Returns "" when there is nothing to add
|
|
813
|
+
— a clean, verified run gets no extra noise.
|
|
814
|
+
"""
|
|
815
|
+
if not isinstance(agent_data, dict):
|
|
816
|
+
return ""
|
|
817
|
+
explanation = agent_data.get("explanation")
|
|
818
|
+
if not isinstance(explanation, dict):
|
|
819
|
+
return ""
|
|
820
|
+
lang = "ko" if str(language or "ko").startswith("ko") else "en"
|
|
821
|
+
|
|
822
|
+
def pick(entry):
|
|
823
|
+
return str(entry.get(lang) or "") if isinstance(entry, dict) else ""
|
|
824
|
+
|
|
825
|
+
details = [pick(item) for item in explanation.get("details") or []]
|
|
826
|
+
details = [line for line in details if line]
|
|
827
|
+
if explanation.get("ok") and not details:
|
|
828
|
+
return ""
|
|
829
|
+
headline = pick(explanation.get("headline"))
|
|
830
|
+
marker = "✅" if explanation.get("ok") else "⚠️"
|
|
831
|
+
lines = [f"{marker} {headline}" if headline else marker]
|
|
832
|
+
lines.extend(f"· {line}" for line in details[:4])
|
|
833
|
+
return "\n".join(lines)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
async def send_run_explanation(client, chat_id, agent_data):
|
|
837
|
+
text = format_run_explanation(agent_data)
|
|
838
|
+
if text:
|
|
839
|
+
await send_message(client, chat_id, text)
|
|
840
|
+
|
|
841
|
+
|
|
668
842
|
def collect_preview_urls(agent_data):
|
|
669
843
|
urls, seen = [], set()
|
|
670
844
|
for step in agent_data.get("steps", []):
|
|
@@ -819,6 +993,7 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
819
993
|
ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
|
|
820
994
|
await send_message(client, chat_id, str(ans))
|
|
821
995
|
if isinstance(data_r, dict):
|
|
996
|
+
await send_run_explanation(client, chat_id, data_r)
|
|
822
997
|
await send_generated_files(client, chat_id, collect_generated_files(data_r))
|
|
823
998
|
await send_preview_links(client, chat_id, collect_preview_urls(data_r))
|
|
824
999
|
except Exception as e:
|
|
@@ -862,7 +1037,12 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
|
862
1037
|
if not ans or not str(ans).strip():
|
|
863
1038
|
ans = "⚠️ AI가 답변을 생성하지 못했습니다."
|
|
864
1039
|
await send_message(client, chat_id, str(ans))
|
|
1040
|
+
if isinstance(data, dict):
|
|
1041
|
+
# Recall parity (v9.9.7): the same grounding verdict the web app
|
|
1042
|
+
# badges, never promoted when the server issued none.
|
|
1043
|
+
await send_grounding_badge(client, chat_id, data)
|
|
865
1044
|
if not image_data and isinstance(data, dict):
|
|
1045
|
+
await send_run_explanation(client, chat_id, data)
|
|
866
1046
|
await send_generated_files(client, chat_id, collect_generated_files(data))
|
|
867
1047
|
await send_preview_links(client, chat_id, collect_preview_urls(data))
|
|
868
1048
|
except Exception as e:
|
|
@@ -887,6 +1067,7 @@ HELP_TEXT = """\
|
|
|
887
1067
|
/clear [n] — 기록 정리 (마지막 n건 유지)
|
|
888
1068
|
/web — 웹 UI 링크
|
|
889
1069
|
/mcp — MCP 도구 목록
|
|
1070
|
+
/review — 변경 제안 검토함 (승인/거절)
|
|
890
1071
|
/help — 이 도움말
|
|
891
1072
|
|
|
892
1073
|
/agent <작업> — 멀티 LLM 에이전트 (계획 확인 후 실행)
|
|
@@ -925,6 +1106,8 @@ async def handle_command(client, chat_id, command: str, args: str):
|
|
|
925
1106
|
await send_web_link(client, chat_id)
|
|
926
1107
|
elif cmd == "mcp":
|
|
927
1108
|
await send_mcp_tools(client, chat_id)
|
|
1109
|
+
elif cmd in {"review", "proposals"}:
|
|
1110
|
+
await show_review_center(client, chat_id)
|
|
928
1111
|
elif cmd in {"help", "h"}:
|
|
929
1112
|
await send_message(client, chat_id, HELP_TEXT)
|
|
930
1113
|
elif cmd == "agent":
|
|
@@ -986,6 +1169,8 @@ async def handle_callback_query(client, callback_query):
|
|
|
986
1169
|
await send_web_link(client, chat_id)
|
|
987
1170
|
elif data == "cmd:mcp":
|
|
988
1171
|
await send_mcp_tools(client, chat_id)
|
|
1172
|
+
elif data == "cmd:review":
|
|
1173
|
+
await show_review_center(client, chat_id)
|
|
989
1174
|
elif data == "cmd:menu":
|
|
990
1175
|
await show_menu(client, chat_id)
|
|
991
1176
|
elif data.startswith("model:unload:"):
|
|
@@ -994,6 +1179,9 @@ async def handle_callback_query(client, callback_query):
|
|
|
994
1179
|
elif data.startswith("plan:"):
|
|
995
1180
|
task = asyncio.create_task(handle_plan_callback(client, chat_id, data))
|
|
996
1181
|
task.add_done_callback(_log_task_exception)
|
|
1182
|
+
elif data.startswith("proposal:"):
|
|
1183
|
+
task = asyncio.create_task(handle_proposal_callback(client, chat_id, data))
|
|
1184
|
+
task.add_done_callback(_log_task_exception)
|
|
997
1185
|
|
|
998
1186
|
# ── Main loop ─────────────────────────────────────────────────────────────────
|
|
999
1187
|
|
|
@@ -4,6 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any, Optional
|
|
6
6
|
|
|
7
|
+
from latticeai.runtime.permission_mode_wiring import (
|
|
8
|
+
bind_dispatch_permission_mode,
|
|
9
|
+
resolve_active_permission_mode,
|
|
10
|
+
)
|
|
7
11
|
from latticeai.services.router_context import InteractionRouterContext, ToolRouterContext
|
|
8
12
|
|
|
9
13
|
|
|
@@ -19,6 +23,9 @@ def build_chat_agent_runtime_from_context(
|
|
|
19
23
|
hooks: Any,
|
|
20
24
|
brain_memory: Any,
|
|
21
25
|
) -> Any:
|
|
26
|
+
# Ensure dispatch + agent share the same autonomy dial before the runtime
|
|
27
|
+
# is constructed (process-wide service; data_dir refined later at router mount).
|
|
28
|
+
bind_dispatch_permission_mode()
|
|
22
29
|
return build_agent_runtime(
|
|
23
30
|
model_router=model_router,
|
|
24
31
|
execute_tool=execute_tool,
|
|
@@ -28,6 +35,7 @@ def build_chat_agent_runtime_from_context(
|
|
|
28
35
|
audit=audit,
|
|
29
36
|
hooks=hooks,
|
|
30
37
|
brain_memory=brain_memory,
|
|
38
|
+
permission_mode=resolve_active_permission_mode,
|
|
31
39
|
)
|
|
32
40
|
|
|
33
41
|
|
|
@@ -13,6 +13,7 @@ def build_context_runtime(
|
|
|
13
13
|
gardener: Any,
|
|
14
14
|
require_auth: bool,
|
|
15
15
|
allowed_scopes_for_user: Callable[[Any], Any],
|
|
16
|
+
artifact_ledger: Any = None,
|
|
16
17
|
) -> Dict[str, Any]:
|
|
17
18
|
"""Construct search, brain memory, and context assembly services."""
|
|
18
19
|
|
|
@@ -41,15 +42,25 @@ def build_context_runtime(
|
|
|
41
42
|
allowed = allowed_scopes_for_user(user_email)
|
|
42
43
|
return gardener.get_relevant_context(q, allowed_workspaces=allowed, **kw)
|
|
43
44
|
|
|
45
|
+
# Re-search loop (v9.9.6): files this conversation just produced are a
|
|
46
|
+
# deterministic context section, so "그 파일에 다크모드 넣어줘" works before
|
|
47
|
+
# asynchronous indexing catches up. Optional — absent ledger, absent section.
|
|
48
|
+
if artifact_ledger is None:
|
|
49
|
+
from latticeai.core.artifact_ledger import ArtifactLedger
|
|
50
|
+
|
|
51
|
+
artifact_ledger = ArtifactLedger()
|
|
52
|
+
|
|
44
53
|
context_assembler = ContextAssembler(
|
|
45
54
|
memory_recall=memory_service.recall,
|
|
46
55
|
hybrid_search=scoped_hybrid_search,
|
|
47
56
|
notes_context=scoped_notes_context,
|
|
57
|
+
recent_artifacts=artifact_ledger.recent,
|
|
48
58
|
)
|
|
49
59
|
|
|
50
60
|
return {
|
|
51
61
|
"SEARCH_SERVICE": search_service,
|
|
52
62
|
"BRAIN_MEMORY": brain_memory,
|
|
53
63
|
"CONTEXT_ASSEMBLER": context_assembler,
|
|
64
|
+
"ARTIFACT_LEDGER": artifact_ledger,
|
|
54
65
|
"_scoped_hybrid_search": scoped_hybrid_search,
|
|
55
66
|
}
|
|
@@ -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(
|