thinkstack-core 4.0.0__py3-none-any.whl
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.
- thinkstack_core/__init__.py +158 -0
- thinkstack_core/aggphi_textual.py +275 -0
- thinkstack_core/alerts/__init__.py +23 -0
- thinkstack_core/alerts/base.py +46 -0
- thinkstack_core/alerts/config.py +60 -0
- thinkstack_core/alerts/dispatcher.py +110 -0
- thinkstack_core/alerts/jira.py +96 -0
- thinkstack_core/alerts/linear.py +72 -0
- thinkstack_core/alerts/pagerduty.py +66 -0
- thinkstack_core/alerts/slack.py +81 -0
- thinkstack_core/alerts/teams.py +70 -0
- thinkstack_core/audit/__init__.py +43 -0
- thinkstack_core/audit/exporter.py +297 -0
- thinkstack_core/audit/privacy.py +101 -0
- thinkstack_core/audit/scrubber.py +149 -0
- thinkstack_core/audit/service.py +67 -0
- thinkstack_core/audit/signing.py +127 -0
- thinkstack_core/broadcast/__init__.py +4 -0
- thinkstack_core/broadcast/broadcaster.py +100 -0
- thinkstack_core/broadcast/watcher.py +71 -0
- thinkstack_core/capability.py +639 -0
- thinkstack_core/cloud/__init__.py +1 -0
- thinkstack_core/cloud/client_config.py +472 -0
- thinkstack_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- thinkstack_core/cloud/client_configs/.claude-stdio.json +13 -0
- thinkstack_core/cloud/client_configs/.cursor-mcp.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode-bridge.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode.json +15 -0
- thinkstack_core/cloud/client_configs/.vscode-mcp.json +13 -0
- thinkstack_core/cloud/mcp_client.py +229 -0
- thinkstack_core/cloud/setup.py +144 -0
- thinkstack_core/cloud/sync.py +143 -0
- thinkstack_core/cloud/sync_bundle.py +639 -0
- thinkstack_core/cloud/sync_conflicts.py +183 -0
- thinkstack_core/cloud/sync_state.py +159 -0
- thinkstack_core/cloud/team_sync.py +337 -0
- thinkstack_core/cloud/thinkstack-mcp-bridge.js +357 -0
- thinkstack_core/codex/__init__.py +9 -0
- thinkstack_core/codex/__main__.py +97 -0
- thinkstack_core/codex/capture.py +208 -0
- thinkstack_core/codex/proxy.py +412 -0
- thinkstack_core/compat.py +103 -0
- thinkstack_core/concept_catalog.py +209 -0
- thinkstack_core/consolidation/__init__.py +3 -0
- thinkstack_core/consolidation/synthesizer.py +87 -0
- thinkstack_core/consolidation/workflow.py +175 -0
- thinkstack_core/daemon/__init__.py +27 -0
- thinkstack_core/daemon/supervisor.py +293 -0
- thinkstack_core/daemon/watcher.py +244 -0
- thinkstack_core/dashboard_api.py +2012 -0
- thinkstack_core/deltaf.py +97 -0
- thinkstack_core/disclosure.py +50 -0
- thinkstack_core/divergence/__init__.py +3 -0
- thinkstack_core/divergence/detector.py +166 -0
- thinkstack_core/gateway/__init__.py +32 -0
- thinkstack_core/gateway/key_manager.py +124 -0
- thinkstack_core/gateway/metrics_webhook.py +252 -0
- thinkstack_core/gateway/policy.py +262 -0
- thinkstack_core/gateway/server.py +727 -0
- thinkstack_core/gateway/sso.py +233 -0
- thinkstack_core/gcc.py +1246 -0
- thinkstack_core/github/__init__.py +35 -0
- thinkstack_core/github/app.py +240 -0
- thinkstack_core/github/comment_builder.py +113 -0
- thinkstack_core/github/pat.py +76 -0
- thinkstack_core/github/pr_parser.py +82 -0
- thinkstack_core/github/pr_reporter.py +555 -0
- thinkstack_core/gitlab/__init__.py +177 -0
- thinkstack_core/hitl/__init__.py +4 -0
- thinkstack_core/hitl/channels.py +129 -0
- thinkstack_core/hitl/orchestrator.py +95 -0
- thinkstack_core/hooks/__init__.py +17 -0
- thinkstack_core/hooks/claude_code.py +228 -0
- thinkstack_core/hooks/git_capture.py +341 -0
- thinkstack_core/hooks/git_commit.py +182 -0
- thinkstack_core/hooks/installer.py +850 -0
- thinkstack_core/hooks/pre_commit.py +157 -0
- thinkstack_core/hooks/runner.py +386 -0
- thinkstack_core/identity/__init__.py +4 -0
- thinkstack_core/identity/agent.py +86 -0
- thinkstack_core/identity/providers.py +85 -0
- thinkstack_core/invariants.py +182 -0
- thinkstack_core/mcp/__init__.py +10 -0
- thinkstack_core/mcp/auth.py +177 -0
- thinkstack_core/mcp/server.py +1215 -0
- thinkstack_core/metrics/__init__.py +35 -0
- thinkstack_core/metrics/aggregate.py +215 -0
- thinkstack_core/metrics/calibrate.py +198 -0
- thinkstack_core/metrics/calibration.py +125 -0
- thinkstack_core/metrics/credibility.py +288 -0
- thinkstack_core/metrics/delivery_time.py +70 -0
- thinkstack_core/metrics/dhs.py +126 -0
- thinkstack_core/metrics/mcs.py +96 -0
- thinkstack_core/metrics/roi.py +88 -0
- thinkstack_core/metrics/session_writer.py +81 -0
- thinkstack_core/metrics/shadow_ai.py +117 -0
- thinkstack_core/metrics/sprint_writer.py +243 -0
- thinkstack_core/observability/__init__.py +78 -0
- thinkstack_core/observability/datadog.py +157 -0
- thinkstack_core/observability/formatter.py +119 -0
- thinkstack_core/observability/report.py +264 -0
- thinkstack_core/observability/servicenow.py +147 -0
- thinkstack_core/observability/splunk.py +218 -0
- thinkstack_core/observability/webhook.py +227 -0
- thinkstack_core/parser/__init__.py +30 -0
- thinkstack_core/parser/blocks.py +216 -0
- thinkstack_core/parser/inference.py +159 -0
- thinkstack_core/parser/thinking.py +112 -0
- thinkstack_core/projects.py +169 -0
- thinkstack_core/prompt_artifact.py +76 -0
- thinkstack_core/proxy/__init__.py +9 -0
- thinkstack_core/proxy/routes/__init__.py +1 -0
- thinkstack_core/proxy/routes/anthropic.py +264 -0
- thinkstack_core/proxy/routes/azure_openai.py +336 -0
- thinkstack_core/proxy/routes/gemini.py +331 -0
- thinkstack_core/proxy/routes/groq.py +284 -0
- thinkstack_core/proxy/routes/ollama.py +279 -0
- thinkstack_core/proxy/routes/openai.py +287 -0
- thinkstack_core/proxy/server.py +356 -0
- thinkstack_core/query/__init__.py +15 -0
- thinkstack_core/query/grep.py +181 -0
- thinkstack_core/query/hybrid.py +86 -0
- thinkstack_core/query/semantic.py +157 -0
- thinkstack_core/rdp.py +105 -0
- thinkstack_core/reasoning/__init__.py +4 -0
- thinkstack_core/reasoning/entry.py +31 -0
- thinkstack_core/reasoning/store.py +122 -0
- thinkstack_core/reasoning_plus/__init__.py +70 -0
- thinkstack_core/reasoning_plus/augmenter.py +337 -0
- thinkstack_core/reasoning_plus/capture.py +51 -0
- thinkstack_core/reasoning_plus/config.py +313 -0
- thinkstack_core/reasoning_plus/context.py +262 -0
- thinkstack_core/reasoning_plus/learning/__init__.py +125 -0
- thinkstack_core/reasoning_plus/learning/analytics.py +141 -0
- thinkstack_core/reasoning_plus/learning/api.py +784 -0
- thinkstack_core/reasoning_plus/learning/chain.py +285 -0
- thinkstack_core/reasoning_plus/learning/composer.py +141 -0
- thinkstack_core/reasoning_plus/learning/conflicts.py +184 -0
- thinkstack_core/reasoning_plus/learning/context_collector.py +194 -0
- thinkstack_core/reasoning_plus/learning/cross_project.py +234 -0
- thinkstack_core/reasoning_plus/learning/deny_list.py +108 -0
- thinkstack_core/reasoning_plus/learning/embeddings.py +209 -0
- thinkstack_core/reasoning_plus/learning/evolution.py +119 -0
- thinkstack_core/reasoning_plus/learning/extractor.py +271 -0
- thinkstack_core/reasoning_plus/learning/filter_five_layer.py +95 -0
- thinkstack_core/reasoning_plus/learning/models.py +149 -0
- thinkstack_core/reasoning_plus/learning/org_store.py +156 -0
- thinkstack_core/reasoning_plus/learning/pii.py +142 -0
- thinkstack_core/reasoning_plus/learning/promotion.py +58 -0
- thinkstack_core/reasoning_plus/learning/provenance.py +126 -0
- thinkstack_core/reasoning_plus/learning/recorder.py +81 -0
- thinkstack_core/reasoning_plus/learning/relevance.py +122 -0
- thinkstack_core/reasoning_plus/learning/state.py +86 -0
- thinkstack_core/reasoning_plus/learning/store.py +178 -0
- thinkstack_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- thinkstack_core/reasoning_plus/prompt.py +90 -0
- thinkstack_core/rep.py +134 -0
- thinkstack_core/rep_network/__init__.py +25 -0
- thinkstack_core/rep_network/merge.py +70 -0
- thinkstack_core/rep_network/node.py +137 -0
- thinkstack_core/rep_network/server.py +140 -0
- thinkstack_core/rep_network/sync.py +207 -0
- thinkstack_core/sensitivity.py +182 -0
- thinkstack_core/serve.py +258 -0
- thinkstack_core/session/__init__.py +39 -0
- thinkstack_core/session/disagreement.py +188 -0
- thinkstack_core/session/models.py +114 -0
- thinkstack_core/session/orchestrator.py +182 -0
- thinkstack_core/session/planner.py +169 -0
- thinkstack_core/session/simulator.py +132 -0
- thinkstack_core/signing.py +290 -0
- thinkstack_core/sis.py +197 -0
- thinkstack_core/skills/pr-reviewer/SKILL.md +204 -0
- thinkstack_core/skills/thinkstack-auto-sync/SKILL.md +175 -0
- thinkstack_core/skills/thinkstack-session-start/SKILL.md +136 -0
- thinkstack_core/storage.py +308 -0
- thinkstack_core/templates/__init__.py +6 -0
- thinkstack_core/templates/engine.py +122 -0
- thinkstack_core/templates/go.py +18 -0
- thinkstack_core/templates/infra.py +19 -0
- thinkstack_core/templates/library/__init__.py +18 -0
- thinkstack_core/templates/library/api_design.md +27 -0
- thinkstack_core/templates/library/bug_fix.md +27 -0
- thinkstack_core/templates/library/decision_record.md +27 -0
- thinkstack_core/templates/library/engine.py +228 -0
- thinkstack_core/templates/library/security_review.md +30 -0
- thinkstack_core/templates/python.py +19 -0
- thinkstack_core/templates/react.py +18 -0
- thinkstack_core/templates/typescript.py +18 -0
- thinkstack_core/theta.py +221 -0
- thinkstack_core/theta_synthesis.py +268 -0
- thinkstack_core/topics.py +320 -0
- thinkstack_core/variance.py +219 -0
- thinkstack_core/wrapper/__init__.py +52 -0
- thinkstack_core/wrapper/anthropic.py +487 -0
- thinkstack_core/wrapper/base.py +562 -0
- thinkstack_core/wrapper/bedrock.py +342 -0
- thinkstack_core/wrapper/gemini.py +422 -0
- thinkstack_core/wrapper/ollama.py +527 -0
- thinkstack_core/wrapper/openai.py +461 -0
- thinkstack_core-4.0.0.dist-info/METADATA +868 -0
- thinkstack_core-4.0.0.dist-info/RECORD +205 -0
- thinkstack_core-4.0.0.dist-info/WHEEL +5 -0
- thinkstack_core-4.0.0.dist-info/entry_points.txt +2 -0
- thinkstack_core-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
thinkstack_core.reasoning_plus.learning.embeddings
|
|
3
|
+
================================================
|
|
4
|
+
Pluggable embedding backends for DRPL relevance.
|
|
5
|
+
|
|
6
|
+
The default backend is keyword-based (no extra dependencies). Users can opt
|
|
7
|
+
into dense semantic embeddings by installing the optional ``embeddings`` extra:
|
|
8
|
+
|
|
9
|
+
pip install thinkstack-core[embeddings]
|
|
10
|
+
|
|
11
|
+
and then configuring the backend:
|
|
12
|
+
|
|
13
|
+
thinkstack reasoning-plus config \
|
|
14
|
+
--learning-embedding-backend sentence_transformers \
|
|
15
|
+
--learning-embedding-model all-MiniLM-L6-v2
|
|
16
|
+
|
|
17
|
+
Supported backends:
|
|
18
|
+
- ``keyword`` — Jaccard similarity over extracted keywords (default)
|
|
19
|
+
- ``sentence_transformers`` — cosine similarity over dense embeddings (local)
|
|
20
|
+
- ``openai`` — cosine similarity over OpenAI embeddings (remote)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from thinkstack_core.reasoning_plus.context import extract_keywords
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("thinkstack.reasoning_plus.learning")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Base class
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
class EmbeddingBackend(ABC):
|
|
39
|
+
"""Abstract embedding backend for DRPL relevance scoring."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def encode(self, text: str) -> Any:
|
|
43
|
+
"""Encode *text* into a backend-specific representation."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def similarity(self, a: Any, b: Any) -> float:
|
|
48
|
+
"""Return a similarity score in [0, 1] for two encodings."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
"""Short identifier for this backend."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Keyword backend (default, no dependencies)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
class KeywordEmbeddingBackend(EmbeddingBackend):
|
|
63
|
+
"""Keyword-overlap backend that uses extracted concepts as a sparse vector."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, max_keywords: int = 15) -> None:
|
|
66
|
+
self.max_keywords = max_keywords
|
|
67
|
+
|
|
68
|
+
def encode(self, text: str) -> set[str]:
|
|
69
|
+
return set(extract_keywords(text, max_keywords=self.max_keywords))
|
|
70
|
+
|
|
71
|
+
def similarity(self, a: set[str], b: set[str]) -> float:
|
|
72
|
+
if not a and not b:
|
|
73
|
+
return 0.0
|
|
74
|
+
overlap = len(a & b)
|
|
75
|
+
total = len(a | b)
|
|
76
|
+
return overlap / total if total else 0.0
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def name(self) -> str:
|
|
80
|
+
return "keyword"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Dense-vector helpers
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
88
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
89
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
90
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
91
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
92
|
+
return 0.0
|
|
93
|
+
return max(0.0, min(1.0, dot / (norm_a * norm_b)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Sentence-transformers backend (optional)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
class SentenceTransformerBackend(EmbeddingBackend):
|
|
101
|
+
"""Local dense embedding backend via sentence-transformers."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
|
|
104
|
+
try:
|
|
105
|
+
from sentence_transformers import SentenceTransformer
|
|
106
|
+
except ImportError as exc:
|
|
107
|
+
raise ImportError(
|
|
108
|
+
"sentence-transformers is not installed. "
|
|
109
|
+
"Install the embeddings extra: pip install thinkstack-core[embeddings]"
|
|
110
|
+
) from exc
|
|
111
|
+
self._model = SentenceTransformer(model_name)
|
|
112
|
+
self._model_name = model_name
|
|
113
|
+
|
|
114
|
+
def encode(self, text: str) -> list[float]:
|
|
115
|
+
import numpy as np
|
|
116
|
+
|
|
117
|
+
vector = self._model.encode(text)
|
|
118
|
+
return vector.tolist()
|
|
119
|
+
|
|
120
|
+
def similarity(self, a: list[float], b: list[float]) -> float:
|
|
121
|
+
return _cosine_similarity(a, b)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def name(self) -> str:
|
|
125
|
+
return "sentence_transformers"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# OpenAI backend (optional)
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
class OpenAIEmbeddingBackend(EmbeddingBackend):
|
|
133
|
+
"""Remote dense embedding backend via OpenAI embeddings API."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
model_name: str = "text-embedding-3-small",
|
|
138
|
+
api_key: str | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
try:
|
|
141
|
+
import openai
|
|
142
|
+
except ImportError as exc:
|
|
143
|
+
raise ImportError(
|
|
144
|
+
"openai is not installed. "
|
|
145
|
+
"Install the wrapper extra: pip install thinkstack-core[wrapper]"
|
|
146
|
+
) from exc
|
|
147
|
+
self._client = openai.OpenAI(api_key=api_key)
|
|
148
|
+
self._model_name = model_name
|
|
149
|
+
|
|
150
|
+
def encode(self, text: str) -> list[float]:
|
|
151
|
+
response = self._client.embeddings.create(
|
|
152
|
+
input=text,
|
|
153
|
+
model=self._model_name,
|
|
154
|
+
)
|
|
155
|
+
return response.data[0].embedding
|
|
156
|
+
|
|
157
|
+
def similarity(self, a: list[float], b: list[float]) -> float:
|
|
158
|
+
return _cosine_similarity(a, b)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def name(self) -> str:
|
|
162
|
+
return "openai"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Fake backend (for tests)
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
class FakeEmbeddingBackend(EmbeddingBackend):
|
|
170
|
+
"""Deterministic fake backend for unit tests."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, embeddings: dict[str, list[float]] | None = None, dim: int = 8) -> None:
|
|
173
|
+
self._embeddings: dict[str, list[float]] = embeddings or {}
|
|
174
|
+
self._dim = dim
|
|
175
|
+
|
|
176
|
+
def encode(self, text: str) -> list[float]:
|
|
177
|
+
return self._embeddings.get(text, [0.0] * self._dim)
|
|
178
|
+
|
|
179
|
+
def similarity(self, a: list[float], b: list[float]) -> float:
|
|
180
|
+
return _cosine_similarity(a, b)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def name(self) -> str:
|
|
184
|
+
return "fake"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Factory
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def get_embedding_backend(
|
|
192
|
+
name: str | None = None,
|
|
193
|
+
model_name: str | None = None,
|
|
194
|
+
max_keywords: int = 15,
|
|
195
|
+
) -> EmbeddingBackend:
|
|
196
|
+
"""
|
|
197
|
+
Return an embedding backend by name.
|
|
198
|
+
|
|
199
|
+
Supported names: ``keyword`` (default), ``sentence_transformers``,
|
|
200
|
+
``sentence-transformers``, ``st``, ``openai``.
|
|
201
|
+
"""
|
|
202
|
+
name = (name or "keyword").lower().strip().replace("-", "_")
|
|
203
|
+
if name in ("keyword", "keywords"):
|
|
204
|
+
return KeywordEmbeddingBackend(max_keywords=max_keywords)
|
|
205
|
+
if name in ("sentence_transformers", "sentence_transformer", "st"):
|
|
206
|
+
return SentenceTransformerBackend(model_name or "all-MiniLM-L6-v2")
|
|
207
|
+
if name == "openai":
|
|
208
|
+
return OpenAIEmbeddingBackend(model_name or "text-embedding-3-small")
|
|
209
|
+
raise ValueError(f"Unknown embedding backend: {name}")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
thinkstack_core.reasoning_plus.learning.evolution
|
|
3
|
+
================================================
|
|
4
|
+
Learning evolution helpers: goal-change detection and confidence decay.
|
|
5
|
+
|
|
6
|
+
Learnings change over time. A learning from week 1 may be superseded by a
|
|
7
|
+
learning from week 3. When the project goal shifts, an older high-confidence
|
|
8
|
+
learning may no longer be valid. And learnings that receive no feedback for
|
|
9
|
+
extended periods should decay in confidence and eventually be marked stale.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from thinkstack_core.reasoning_plus.learning.models import Learning
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("thinkstack.reasoning_plus.learning")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Goal change detection
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_GOAL_PATTERN = "GOAL:"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def goal_changed_since(learning_id: str, gcc_dir: Path | str) -> bool:
|
|
31
|
+
"""Return True if a goal-setting commit appears in the GCC event log.
|
|
32
|
+
|
|
33
|
+
Goal-setting commits are detected by the ``GOAL:`` prefix in the commit
|
|
34
|
+
message (the convention is ``thinkstack commit -m "GOAL: ..."``). The
|
|
35
|
+
``learning_id`` is accepted for forward-compatibility (per-learning goal
|
|
36
|
+
tracking) but the current implementation checks for *any* goal commit in
|
|
37
|
+
the event log, which is sufficient for the resolution matrix.
|
|
38
|
+
|
|
39
|
+
If the event log exists but cannot be read (permissions, lock contention),
|
|
40
|
+
the function returns ``True`` as a conservative default — this ensures
|
|
41
|
+
conflicts are flagged for human review rather than auto-resolved with
|
|
42
|
+
stale information.
|
|
43
|
+
"""
|
|
44
|
+
gcc_dir = Path(gcc_dir)
|
|
45
|
+
events_path = gcc_dir / "events.log.jsonl"
|
|
46
|
+
if not events_path.exists():
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
for line in events_path.read_text(encoding="utf-8").splitlines():
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if not line:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
event = json.loads(line)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
continue
|
|
57
|
+
message = event.get("message", "") or ""
|
|
58
|
+
if isinstance(message, str) and message.upper().startswith(_GOAL_PATTERN):
|
|
59
|
+
return True
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
logger.warning("thinkstack: failed to read events log for goal change — %s", exc)
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Confidence decay
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
_DECAY_WEEKLY_AMOUNT = 0.05 # confidence -= 0.05 per week without feedback
|
|
71
|
+
_DECAY_THRESHOLD_DAYS = 7 # start decaying after 7 days
|
|
72
|
+
_STALE_THRESHOLD_DAYS = 30 # mark stale after 30 days
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _days_since(ts: str) -> float:
|
|
76
|
+
"""Return days elapsed since an ISO timestamp string."""
|
|
77
|
+
try:
|
|
78
|
+
dt = datetime.fromisoformat(ts)
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
return 0.0
|
|
81
|
+
if dt.tzinfo is None:
|
|
82
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
83
|
+
return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def apply_confidence_decay(
|
|
87
|
+
learning: Learning,
|
|
88
|
+
*,
|
|
89
|
+
now: datetime | None = None,
|
|
90
|
+
) -> Learning:
|
|
91
|
+
"""Apply time-based confidence decay to a learning in-place and return it.
|
|
92
|
+
|
|
93
|
+
Rules:
|
|
94
|
+
- ``"canon"`` learnings are immune (human-endorsed canonical rules).
|
|
95
|
+
- Already deprecated learnings are left as-is.
|
|
96
|
+
- After ``_STALE_THRESHOLD_DAYS`` (30) days without feedback: validity
|
|
97
|
+
becomes ``"stale"``.
|
|
98
|
+
- After ``_DECAY_THRESHOLD_DAYS`` (7) days: confidence decays by
|
|
99
|
+
``_DECAY_WEEKLY_AMOUNT`` (0.05) per elapsed week, floored at 0.0.
|
|
100
|
+
"""
|
|
101
|
+
if learning.type == "canon":
|
|
102
|
+
return learning
|
|
103
|
+
if learning.validity == "deprecated":
|
|
104
|
+
return learning
|
|
105
|
+
|
|
106
|
+
now = now or datetime.now(timezone.utc)
|
|
107
|
+
days = _days_since(learning.updated_at)
|
|
108
|
+
|
|
109
|
+
if days < _DECAY_THRESHOLD_DAYS:
|
|
110
|
+
return learning
|
|
111
|
+
|
|
112
|
+
weeks = int(days // 7)
|
|
113
|
+
learning.confidence = max(0.0, learning.confidence - weeks * _DECAY_WEEKLY_AMOUNT)
|
|
114
|
+
|
|
115
|
+
if days > _STALE_THRESHOLD_DAYS:
|
|
116
|
+
learning.validity = "stale"
|
|
117
|
+
|
|
118
|
+
learning.updated_at = now.isoformat()
|
|
119
|
+
return learning
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
thinkstack_core.reasoning_plus.learning.extractor
|
|
3
|
+
===============================================
|
|
4
|
+
Distill ReasoningCall records into reusable Learning objects.
|
|
5
|
+
|
|
6
|
+
Two strategies are supported:
|
|
7
|
+
|
|
8
|
+
1. **Rule-based** (default, no dependencies) — pair captured reasoning with the
|
|
9
|
+
observed outcome and synthesize a compact learning sentence.
|
|
10
|
+
2. **LLM-based** (optional) — pass the reasoning, input, output, outcome, and
|
|
11
|
+
enriched extraction context to a small LLM prompt that returns a concise
|
|
12
|
+
learning summary. The caller supplies the LLM client, so the core library
|
|
13
|
+
remains dependency-free.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from thinkstack_core.reasoning_plus.context import extract_keywords
|
|
21
|
+
from thinkstack_core.reasoning_plus.learning.context_collector import ExtractionContext
|
|
22
|
+
from thinkstack_core.reasoning_plus.learning.models import Learning, ReasoningCall
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("thinkstack.reasoning_plus.learning")
|
|
25
|
+
|
|
26
|
+
DEFAULT_EXTRACTION_PROMPT = """\
|
|
27
|
+
You are distilling a previous reasoning step into a short, reusable insight.
|
|
28
|
+
Given the reasoning, the action's input, its output, and the observed outcome,
|
|
29
|
+
write one concise sentence that another AI agent could reuse when facing a
|
|
30
|
+
similar situation.
|
|
31
|
+
|
|
32
|
+
Outcome: {outcome}
|
|
33
|
+
Name: {name}
|
|
34
|
+
Input: {input}
|
|
35
|
+
Output: {output}
|
|
36
|
+
Reasoning: {reasoning}
|
|
37
|
+
|
|
38
|
+
Insight:"""
|
|
39
|
+
|
|
40
|
+
CONTEXT_AWARE_EXTRACTION_PROMPT = """\
|
|
41
|
+
You are extracting learnings from an AI agent's reasoning step. The agent
|
|
42
|
+
may have changed files, added or removed imports, and used specific libraries.
|
|
43
|
+
|
|
44
|
+
{context_section}
|
|
45
|
+
|
|
46
|
+
Agent reasoning:
|
|
47
|
+
{reasoning}
|
|
48
|
+
|
|
49
|
+
Extract what another AI agent should learn from this step. Classify as:
|
|
50
|
+
- "prescription": Concrete, actionable recommendation (use library X, version Y, configure Z)
|
|
51
|
+
- "pattern": General pattern that emerged (structuring code, error handling, etc.)
|
|
52
|
+
- "avoid": Something that should NOT be done
|
|
53
|
+
- "insight": Non-obvious observation about the codebase, architecture, or approach
|
|
54
|
+
- "correction": A mistake that was made and then fixed
|
|
55
|
+
- "confirm": A previously uncertain approach that proved correct
|
|
56
|
+
|
|
57
|
+
Return a JSON object with the following structure:
|
|
58
|
+
{{
|
|
59
|
+
"type": "<type>",
|
|
60
|
+
"content": "<one concise, actionable sentence>",
|
|
61
|
+
"trigger_concepts": ["<concept1>", "<concept2>"],
|
|
62
|
+
"confidence": <0.0-1.0>
|
|
63
|
+
}}
|
|
64
|
+
|
|
65
|
+
If the context shows a concrete library, framework, or tool being used,
|
|
66
|
+
include "prescription" as the type and specify the exact library, version,
|
|
67
|
+
and usage pattern in the content."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Helpers
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _type_for_outcome(outcome: str, context: ExtractionContext | None = None) -> str:
|
|
75
|
+
if context and context.is_significant():
|
|
76
|
+
if context.imports_added or context.libraries_used:
|
|
77
|
+
return "prescription"
|
|
78
|
+
if outcome == "success":
|
|
79
|
+
return "confirm"
|
|
80
|
+
if outcome == "failure":
|
|
81
|
+
return "correction"
|
|
82
|
+
if outcome == "partial":
|
|
83
|
+
return "insight"
|
|
84
|
+
return "insight"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _extract_concepts(call: ReasoningCall, context: ExtractionContext | None = None) -> list[str]:
|
|
88
|
+
concepts = set(extract_keywords(call.reasoning, max_keywords=10))
|
|
89
|
+
# Add import symbols as concepts for better cross-task matching.
|
|
90
|
+
# File names are intentionally NOT added — they cause near-identical
|
|
91
|
+
# learnings to diverge on file path alone, defeating deduplication.
|
|
92
|
+
if context:
|
|
93
|
+
for imp in context.imports_added[:3]:
|
|
94
|
+
concept = imp.rsplit(".", 1)[-1]
|
|
95
|
+
concepts.add(concept)
|
|
96
|
+
# Ensure we always have at least some concepts
|
|
97
|
+
if not concepts:
|
|
98
|
+
concepts = set(extract_keywords(call.input + " " + call.output, max_keywords=5))
|
|
99
|
+
return sorted(concepts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fallback_reasoning(call: ReasoningCall) -> str:
|
|
103
|
+
"""When the call has no captured reasoning, synthesize a short summary."""
|
|
104
|
+
if call.outcome not in ("success", "failure"):
|
|
105
|
+
return ""
|
|
106
|
+
name = call.name or call.call_type
|
|
107
|
+
if call.outcome == "failure":
|
|
108
|
+
return f"{name} failed. Input: {call.input}. Output: {call.output}."
|
|
109
|
+
return f"{name} succeeded. Input: {call.input}. Output: {call.output}."
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _compose_content_from_reasoning(call: ReasoningCall, reasoning: str, context: ExtractionContext | None = None) -> str:
|
|
113
|
+
reasoning = reasoning.strip().replace("\n", " ")
|
|
114
|
+
# Truncate very long reasoning: keep first 200 + last 200 chars
|
|
115
|
+
if len(reasoning) > 400:
|
|
116
|
+
reasoning = reasoning[:197].rstrip() + " ... " + reasoning[-200:].lstrip()
|
|
117
|
+
|
|
118
|
+
name = call.name or call.call_type
|
|
119
|
+
|
|
120
|
+
# Return clean reasoning content — no boilerplate prefixes.
|
|
121
|
+
# Context metadata (files, imports, libraries) goes into trigger_concepts.
|
|
122
|
+
return reasoning
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_learning(call: ReasoningCall, reasoning: str, context: ExtractionContext | None = None) -> Learning:
|
|
126
|
+
"""Build a single Learning object from a reasoning summary and optional extraction context."""
|
|
127
|
+
learning_type = _type_for_outcome(call.outcome, context)
|
|
128
|
+
content = _compose_content_from_reasoning(call, reasoning, context)
|
|
129
|
+
concepts = _extract_concepts(call, context)
|
|
130
|
+
|
|
131
|
+
# Start with moderate confidence so feedback can raise or lower it.
|
|
132
|
+
base_confidence = min(call.confidence, 0.8)
|
|
133
|
+
|
|
134
|
+
meta: dict[str, str] = {"call_type": call.call_type, "name": call.name, "outcome": call.outcome}
|
|
135
|
+
if context and context.is_significant():
|
|
136
|
+
meta["context_files"] = ",".join(context.files_changed[:5])
|
|
137
|
+
meta["context_imports"] = ",".join(context.imports_added[:5])
|
|
138
|
+
|
|
139
|
+
return Learning(
|
|
140
|
+
content=content,
|
|
141
|
+
type=learning_type,
|
|
142
|
+
trigger_concepts=concepts,
|
|
143
|
+
state_hash=call.state_hash,
|
|
144
|
+
confidence=base_confidence,
|
|
145
|
+
source_reasoning_ids=[call.id],
|
|
146
|
+
sensitivity=call.sensitivity,
|
|
147
|
+
meta=meta,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Rule-based extractor
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
class LearningExtractor:
|
|
156
|
+
"""Extract learnings from a single reasoning call using lightweight rules."""
|
|
157
|
+
|
|
158
|
+
def extract(self, call: ReasoningCall, context: ExtractionContext | None = None) -> list[Learning]:
|
|
159
|
+
"""
|
|
160
|
+
Return one or more Learning objects derived from the call.
|
|
161
|
+
|
|
162
|
+
If extraction context is provided, the rule-based extractor uses it to
|
|
163
|
+
determine whether the learning should be classified as a "prescription"
|
|
164
|
+
(when concrete imports, libraries, or file changes are detected).
|
|
165
|
+
"""
|
|
166
|
+
reasoning = call.reasoning.strip()
|
|
167
|
+
if not reasoning:
|
|
168
|
+
reasoning = _fallback_reasoning(call)
|
|
169
|
+
|
|
170
|
+
if not reasoning:
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
return [_build_learning(call, reasoning, context)]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Backwards-compatible alias
|
|
177
|
+
RuleBasedLearningExtractor = LearningExtractor
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# LLM-based extractor
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
class LLMBasedLearningExtractor(LearningExtractor):
|
|
185
|
+
"""
|
|
186
|
+
Extract learnings by asking an LLM to summarize a reasoning call.
|
|
187
|
+
|
|
188
|
+
The caller supplies ``client``, a callable that takes a prompt string and
|
|
189
|
+
returns a completion string. This keeps the core library free of any LLM
|
|
190
|
+
SDK dependency.
|
|
191
|
+
|
|
192
|
+
When ``context`` is provided (an ``ExtractionContext`` with files, imports,
|
|
193
|
+
and libraries), the extractor uses a richer prompt that generates concrete,
|
|
194
|
+
prescriptive learnings.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
client: Callable[[str], str],
|
|
200
|
+
prompt_template: str | None = None,
|
|
201
|
+
context_prompt_template: str | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
self._client = client
|
|
204
|
+
self._prompt_template = prompt_template or DEFAULT_EXTRACTION_PROMPT
|
|
205
|
+
self._context_prompt_template = context_prompt_template or CONTEXT_AWARE_EXTRACTION_PROMPT
|
|
206
|
+
|
|
207
|
+
def extract(self, call: ReasoningCall, context: ExtractionContext | None = None) -> list[Learning]:
|
|
208
|
+
"""Use an LLM to summarize the reasoning; fall back to rule-based if the LLM fails."""
|
|
209
|
+
reasoning = call.reasoning.strip()
|
|
210
|
+
if not reasoning:
|
|
211
|
+
reasoning = _fallback_reasoning(call)
|
|
212
|
+
|
|
213
|
+
if not reasoning:
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
prompt = self._build_prompt(call, reasoning, context)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
summary = self._client(prompt).strip()
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
logger.warning("thinkstack: LLM-based extraction failed — %s", exc)
|
|
222
|
+
return [_build_learning(call, reasoning, context)]
|
|
223
|
+
|
|
224
|
+
if not summary:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
return [_build_learning(call, summary, context)]
|
|
228
|
+
|
|
229
|
+
def _build_prompt(self, call: ReasoningCall, reasoning: str, context: ExtractionContext | None) -> str:
|
|
230
|
+
"""Build the extraction prompt, selecting context-aware or default template."""
|
|
231
|
+
if context and context.is_significant():
|
|
232
|
+
template = self._context_prompt_template
|
|
233
|
+
context_section = context.to_prompt_context()
|
|
234
|
+
return template.format(
|
|
235
|
+
context_section=context_section,
|
|
236
|
+
reasoning=reasoning,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return self._prompt_template.format(
|
|
240
|
+
reasoning=reasoning,
|
|
241
|
+
input=call.input,
|
|
242
|
+
output=call.output,
|
|
243
|
+
outcome=call.outcome,
|
|
244
|
+
name=call.name or call.call_type,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Factory
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def make_extractor(
|
|
253
|
+
strategy: str = "rule",
|
|
254
|
+
llm_client: Callable[[str], str] | None = None,
|
|
255
|
+
context_prompt_template: str | None = None,
|
|
256
|
+
) -> LearningExtractor:
|
|
257
|
+
"""
|
|
258
|
+
Build a learning extractor for the given strategy.
|
|
259
|
+
|
|
260
|
+
Strategies:
|
|
261
|
+
- ``rule`` (default): lightweight rule-based extraction.
|
|
262
|
+
- ``llm``: LLM-based extraction; requires a ``llm_client`` callable.
|
|
263
|
+
|
|
264
|
+
If ``llm`` is requested but no client is provided, falls back to rule-based.
|
|
265
|
+
"""
|
|
266
|
+
if strategy == "llm" and llm_client is not None:
|
|
267
|
+
return LLMBasedLearningExtractor(
|
|
268
|
+
llm_client,
|
|
269
|
+
context_prompt_template=context_prompt_template or CONTEXT_AWARE_EXTRACTION_PROMPT,
|
|
270
|
+
)
|
|
271
|
+
return LearningExtractor()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
thinkstack_core.reasoning_plus.learning.filter_five_layer
|
|
3
|
+
========================================================
|
|
4
|
+
Five-layer cross-project learning relevance filter.
|
|
5
|
+
|
|
6
|
+
Only learnings that pass all five layers are eligible for injection
|
|
7
|
+
into a target project's agent workflow:
|
|
8
|
+
|
|
9
|
+
1. **Concept mismatch** — the target project must share at least one
|
|
10
|
+
trigger concept with the learning.
|
|
11
|
+
2. **Scope gate** — ``scope: "project"`` and ``scope: "branch"``
|
|
12
|
+
learnings are excluded from cross-project injection.
|
|
13
|
+
3. **Deny list** — per-project ``.GCC/learning_config.json`` can
|
|
14
|
+
exclude specific concepts or learning IDs.
|
|
15
|
+
4. **Relevance score** — similarity between the learning's content and
|
|
16
|
+
the current prompt must exceed ``learning_relevance_threshold``.
|
|
17
|
+
5. **Human promotion** — auto-applied learnings must have ``scope: "org"``
|
|
18
|
+
and ``type: "canon"``; others require explicit opt-in.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from thinkstack_core.reasoning_plus.learning.deny_list import (
|
|
27
|
+
DenyList,
|
|
28
|
+
load_deny_list,
|
|
29
|
+
)
|
|
30
|
+
from thinkstack_core.reasoning_plus.learning.models import Learning
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("thinkstack.reasoning_plus.learning")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def five_layer_filter(
|
|
36
|
+
learning: Learning,
|
|
37
|
+
*,
|
|
38
|
+
target_project_root: Path | str,
|
|
39
|
+
prompt_context: str = "",
|
|
40
|
+
relevance_score: float = 0.0,
|
|
41
|
+
deny_list: DenyList | None = None,
|
|
42
|
+
min_relevance: float = 0.3,
|
|
43
|
+
) -> tuple[bool, str]:
|
|
44
|
+
"""Return (allowed, reason) after evaluating all five layers.
|
|
45
|
+
|
|
46
|
+
Returns ``(True, "ALLOWED")`` if the learning passes every layer.
|
|
47
|
+
Returns ``(False, reason_string)`` identifying the first failing layer.
|
|
48
|
+
"""
|
|
49
|
+
target = Path(target_project_root)
|
|
50
|
+
|
|
51
|
+
# Layer 1: Concept mismatch
|
|
52
|
+
target_concepts = _load_project_concepts(target)
|
|
53
|
+
if target_concepts:
|
|
54
|
+
overlap = set(c.lower() for c in learning.trigger_concepts) & set(c.lower() for c in target_concepts)
|
|
55
|
+
|
|
56
|
+
has_any_concept = len(overlap) > 0
|
|
57
|
+
else:
|
|
58
|
+
has_any_concept = len(learning.trigger_concepts) > 0
|
|
59
|
+
# No target concepts defined — allow through rather than block.
|
|
60
|
+
|
|
61
|
+
if not has_any_concept:
|
|
62
|
+
return False, "CONCEPT_MISMATCH"
|
|
63
|
+
|
|
64
|
+
# Layer 2: Scope gate — only org-scope learnings cross projects
|
|
65
|
+
if learning.scope in ("project", "branch"):
|
|
66
|
+
return False, f"SCOPE_GATE:{learning.scope}"
|
|
67
|
+
|
|
68
|
+
# Layer 3: Deny list
|
|
69
|
+
dl = deny_list or load_deny_list(target)
|
|
70
|
+
if learning.id in dl.excluded_learnings:
|
|
71
|
+
return False, "DENY_LIST:learning_id"
|
|
72
|
+
for concept in learning.trigger_concepts:
|
|
73
|
+
if concept.lower() in (c.lower() for c in dl.excluded_concepts):
|
|
74
|
+
return False, f"DENY_LIST:concept:{concept}"
|
|
75
|
+
|
|
76
|
+
# Layer 4: Relevance threshold
|
|
77
|
+
if relevance_score < min_relevance:
|
|
78
|
+
return False, f"RELEVANCE_THRESHOLD:{relevance_score:.2f}<{min_relevance:.2f}"
|
|
79
|
+
|
|
80
|
+
# Layer 5: Human promotion gate
|
|
81
|
+
# Auto-apply only if org-scope + canon type; everything else needs explicit
|
|
82
|
+
if learning.scope == "org" and learning.type == "canon":
|
|
83
|
+
return True, "ALLOWED"
|
|
84
|
+
return False, "PROMOTION_REQUIRED"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_project_concepts(project_root: Path) -> list[str]:
|
|
88
|
+
concepts_dir = project_root / ".GCC" / "concepts"
|
|
89
|
+
if not concepts_dir.exists():
|
|
90
|
+
return []
|
|
91
|
+
concepts: list[str] = []
|
|
92
|
+
for f in concepts_dir.iterdir():
|
|
93
|
+
if f.suffix in (".md", ".json") and f.is_file():
|
|
94
|
+
concepts.append(f.stem)
|
|
95
|
+
return concepts
|