Merge pull request 'Merge dev → main: family precedents say their reason once; the repeat trigger compares like with like' (#208) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 22s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 22s
This commit was merged in pull request #208.
This commit is contained in:
@@ -33,7 +33,8 @@ async def list_family_ideas(
|
|||||||
|
|
||||||
async def get_family_idea(note_id: int) -> dict:
|
async def get_family_idea(note_id: int) -> dict:
|
||||||
"""One family idea with what an evaluation needs: its state, platforms,
|
"""One family idea with what an evaluation needs: its state, platforms,
|
||||||
full decision history, ledger counts, THE THREE CRITERIA, the `ledger`
|
decision history (brief — each reason once; list_family_decisions has
|
||||||
|
the full rows), ledger counts, THE THREE CRITERIA, the `ledger`
|
||||||
(every project's answer, with `needs_recheck`), and the `precedents` —
|
(every project's answer, with `needs_recheck`), and the `precedents` —
|
||||||
the decisions on the ideas nearest this one by meaning.
|
the decisions on the ideas nearest this one by meaning.
|
||||||
Read the precedents before deciding, and decide consistently with them
|
Read the precedents before deciding, and decide consistently with them
|
||||||
|
|||||||
@@ -357,3 +357,37 @@ class FamilyDecision(Base, CreatedAtMixin):
|
|||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"created_at": iso(self.created_at),
|
"created_at": iso(self.created_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def to_brief(self) -> dict:
|
||||||
|
"""The decision as a reader WEIGHING it needs it: what was decided,
|
||||||
|
why — once — and the state it moved between.
|
||||||
|
|
||||||
|
Precedents and an idea's history are read in bulk, a handful per
|
||||||
|
call, and the full row repeats itself: an assessment's reason is
|
||||||
|
stored again inside `after` (and the prior one inside `before`), and
|
||||||
|
a promotion's evidence carries the whole criteria reasoning. So a
|
||||||
|
state here is its status, version and platforms only, and evidence is
|
||||||
|
the cited list alone (#5131). The full row is `to_dict`, which the
|
||||||
|
decision log serves.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"idea_id": self.idea_id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"action": self.action,
|
||||||
|
"reason": self.reason,
|
||||||
|
"before": _brief_state(self.before),
|
||||||
|
"after": _brief_state(self.after),
|
||||||
|
"evidence": list((self.evidence or {}).get("evidence") or []),
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# What a decision's before/after keeps in its brief: the state, not the prose.
|
||||||
|
_BRIEF_STATE_KEYS = ("status", "canon_version", "platforms")
|
||||||
|
|
||||||
|
|
||||||
|
def _brief_state(state: dict | None) -> dict | None:
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return {k: state[k] for k in _BRIEF_STATE_KEYS if k in state}
|
||||||
|
|||||||
@@ -119,7 +119,15 @@ _LINEAGE = re.compile(
|
|||||||
_LINEAGE_WINDOW = 80
|
_LINEAGE_WINDOW = 80
|
||||||
|
|
||||||
# Note kinds the repeat trigger compares: recorded knowledge and shapes, not
|
# Note kinds the repeat trigger compares: recorded knowledge and shapes, not
|
||||||
# the to-do list.
|
# the to-do list — and each only against its own kind (#5130). A repeat is the
|
||||||
|
# same KIND of thing made twice: two shapes built, or two ideas written down.
|
||||||
|
# Across kinds the score stops meaning that. One chunk of a long narrative
|
||||||
|
# note that mentions a shape's vocabulary scores like the shape itself: a
|
||||||
|
# dated release log matched a signed-APK CI snippet at 0.83, above this
|
||||||
|
# trigger's floor, and opened the log as a candidate. The cross-kind relation
|
||||||
|
# that IS real — code built from an idea written elsewhere — has its own
|
||||||
|
# routes: the citation trigger when the lineage is named, and the shape ledger
|
||||||
|
# once the idea is canon.
|
||||||
_REPEAT_KINDS = ("snippet", "note")
|
_REPEAT_KINDS = ("snippet", "note")
|
||||||
|
|
||||||
MEMBER_STATES = ("declared", "detected")
|
MEMBER_STATES = ("declared", "detected")
|
||||||
@@ -211,7 +219,8 @@ async def get_idea(user_id: int, note_id: int) -> dict | None:
|
|||||||
"platforms": await _platform_slugs(session, note_id),
|
"platforms": await _platform_slugs(session, note_id),
|
||||||
"topic": await _topic(session, user_id, idea.topic_id, rules=True),
|
"topic": await _topic(session, user_id, idea.topic_id, rules=True),
|
||||||
"adoptions": counts,
|
"adoptions": counts,
|
||||||
"decisions": [_decision_dict(d) for d in decisions],
|
# Brief: the history is read in bulk; the full rows are list_decisions (#5131).
|
||||||
|
"decisions": [d.to_brief() for d in decisions],
|
||||||
"undoable_decision_id": _undoable_id(decisions),
|
"undoable_decision_id": _undoable_id(decisions),
|
||||||
})
|
})
|
||||||
out["criteria"] = list(CRITERIA)
|
out["criteria"] = list(CRITERIA)
|
||||||
@@ -383,8 +392,8 @@ async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
|
|||||||
for score, n in ranked:
|
for score, n in ranked:
|
||||||
d = decisions.get(latest.get(n.id))
|
d = decisions.get(latest.get(n.id))
|
||||||
if d is not None:
|
if d is not None:
|
||||||
item = _decision_dict(d, n.title)
|
item = {**d.to_brief(), "idea_title": n.title,
|
||||||
item["similarity"] = round(float(score), 3)
|
"similarity": round(float(score), 3)}
|
||||||
out.append(item)
|
out.append(item)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -927,13 +936,15 @@ async def citation_trigger(user_id: int, note) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
async def repeat_trigger(user_id: int, note) -> str | None:
|
async def repeat_trigger(user_id: int, note) -> str | None:
|
||||||
"""A new record whose meaning repeats a record in ANOTHER project that
|
"""A new record whose meaning repeats a record OF THE SAME KIND in another
|
||||||
shares a platform with this one opens an evaluation of the earlier record.
|
project that shares a platform with this one opens an evaluation of the
|
||||||
Silent when the projects share no platform, below the threshold, or when
|
earlier record. Silent when the projects share no platform, below the
|
||||||
the project has no platforms yet."""
|
threshold, when the project has no platforms yet, or when the only match
|
||||||
|
is a different kind (`_REPEAT_KINDS` says why)."""
|
||||||
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||||
|
|
||||||
if not getattr(note, "project_id", None) or note.is_task:
|
kind = getattr(note, "note_type", None) or "note"
|
||||||
|
if not getattr(note, "project_id", None) or note.is_task or kind not in _REPEAT_KINDS:
|
||||||
return None
|
return None
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
mine = await _member_platform_ids(session, note.project_id)
|
mine = await _member_platform_ids(session, note.project_id)
|
||||||
@@ -941,12 +952,14 @@ async def repeat_trigger(user_id: int, note) -> str | None:
|
|||||||
return None
|
return None
|
||||||
hits = await semantic_search_notes(
|
hits = await semantic_search_notes(
|
||||||
user_id, embedding_text(note.title, note.body), exclude_ids={note.id},
|
user_id, embedding_text(note.title, note.body), exclude_ids={note.id},
|
||||||
limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=_REPEAT_KINDS,
|
limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=kind,
|
||||||
is_task=False, demote_superseded=False,
|
is_task=False, demote_superseded=False,
|
||||||
)
|
)
|
||||||
for score, other in hits:
|
for score, other in hits:
|
||||||
if not other.project_id or other.project_id == note.project_id:
|
if not other.project_id or other.project_id == note.project_id:
|
||||||
continue
|
continue
|
||||||
|
if (other.note_type or "note") != kind:
|
||||||
|
continue
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
shared = mine & await _member_platform_ids(session, other.project_id)
|
shared = mine & await _member_platform_ids(session, other.project_id)
|
||||||
if not shared or not await access.can_write_note(user_id, other.id):
|
if not shared or not await access.can_write_note(user_id, other.id):
|
||||||
|
|||||||
@@ -545,8 +545,7 @@ async def assessment_precedents(user_id: int, project_id: int, idea_id: int,
|
|||||||
for d, idea_title, project_title in rows:
|
for d, idea_title, project_title in rows:
|
||||||
if not await access.can_read_project(user_id, d.project_id):
|
if not await access.can_read_project(user_id, d.project_id):
|
||||||
continue
|
continue
|
||||||
item = d.to_dict()
|
item = {**d.to_brief(), "idea_title": idea_title, "project_title": project_title}
|
||||||
item.update({"idea_title": idea_title, "project_title": project_title})
|
|
||||||
if d.idea_id == idea_id:
|
if d.idea_id == idea_id:
|
||||||
item["relation"] = "same idea, another project"
|
item["relation"] = "same idea, another project"
|
||||||
out_same.append(item)
|
out_same.append(item)
|
||||||
|
|||||||
@@ -67,3 +67,47 @@ def test_a_canon_ideas_scope_lives_on_the_idea_and_nowhere_else():
|
|||||||
|
|
||||||
assert "topic_id" in family.FamilyIdea.__table__.c
|
assert "topic_id" in family.FamilyIdea.__table__.c
|
||||||
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
|
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- a decision read as precedent (#5131) ------------------------------------------
|
||||||
|
|
||||||
|
def _assessment():
|
||||||
|
reason = "the APK is built in CI and signed with the one release key"
|
||||||
|
return family.FamilyDecision(
|
||||||
|
id=7, idea_id=3, project_id=5, action="assess", reason=reason,
|
||||||
|
before={"status": "unassessed", "reason": "", "canon_version": None},
|
||||||
|
after={"status": "adopted", "reason": reason, "canon_version": 2},
|
||||||
|
evidence={"evidence": ["ci.yml::android-release"], "source": "shapes"},
|
||||||
|
precedent_ids=[4, 6], decided_via="agent", user_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_brief_carries_the_reason_once():
|
||||||
|
brief = _assessment().to_brief()
|
||||||
|
assert brief["reason"].startswith("the APK is built")
|
||||||
|
assert brief["before"] == {"status": "unassessed", "canon_version": None}
|
||||||
|
assert brief["after"] == {"status": "adopted", "canon_version": 2}
|
||||||
|
# Nothing in it says the reason a second time.
|
||||||
|
assert repr(brief).count("the APK is built") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_brief_keeps_the_cited_evidence_and_drops_the_rest():
|
||||||
|
promotion = family.FamilyDecision(
|
||||||
|
id=8, idea_id=3, project_id=None, action="promote", reason="proven twice",
|
||||||
|
before=None,
|
||||||
|
after={"status": "canon", "applies_when": "an app that ships its own APK",
|
||||||
|
"canon_version": 1, "platforms": ["android-app"]},
|
||||||
|
evidence={"criteria": {"proven": "a long paragraph"}, "evidence": ["#12"],
|
||||||
|
"ledger_rows_opened": 3},
|
||||||
|
)
|
||||||
|
brief = promotion.to_brief()
|
||||||
|
assert brief["evidence"] == ["#12"]
|
||||||
|
assert brief["before"] is None
|
||||||
|
assert brief["after"] == {"status": "canon", "canon_version": 1,
|
||||||
|
"platforms": ["android-app"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_full_row_is_still_the_audit_shape():
|
||||||
|
full = _assessment().to_dict()
|
||||||
|
assert full["after"]["reason"] == full["reason"]
|
||||||
|
assert full["evidence"]["source"] == "shapes" and full["precedent_ids"] == [4, 6]
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ async def test_another_projects_answer_is_recorded_as_precedent(family):
|
|||||||
assert first["decision"]["id"] in second["decision"]["precedent_ids"]
|
assert first["decision"]["id"] in second["decision"]["precedent_ids"]
|
||||||
assert second["precedents"][0]["relation"] == "same idea, another project"
|
assert second["precedents"][0]["relation"] == "same idea, another project"
|
||||||
assert second["precedents"][0]["project_title"] == "android one"
|
assert second["precedents"][0]["project_title"] == "android one"
|
||||||
|
# A precedent says its reason once: before/after are state only (#5131).
|
||||||
|
assert second["precedents"][0]["reason"] == "the source app"
|
||||||
|
assert "reason" not in second["precedents"][0]["after"]
|
||||||
|
|
||||||
|
|
||||||
# --- recheck --------------------------------------------------------------------------
|
# --- recheck --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -278,6 +278,26 @@ async def test_a_repeat_with_no_shared_platform_is_silent(family):
|
|||||||
assert await _idea(family["note"]) is None
|
assert await _idea(family["note"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_repeat_across_kinds_is_silent(family):
|
||||||
|
"""A snippet whose only match is a NOTE is not a repeat (#5130): a dated
|
||||||
|
release log scored 0.83 against a signed-APK CI snippet on shared
|
||||||
|
vocabulary and was opened as a candidate. The stub hands back the
|
||||||
|
cross-kind hit directly, so this pins the trigger's own check, not the
|
||||||
|
search filter in front of it."""
|
||||||
|
async with async_session() as s:
|
||||||
|
snippet = Note(user_id=family["owner"], project_id=family["b"], note_type="snippet",
|
||||||
|
title="android-release lane", body="sign with the one release key")
|
||||||
|
s.add(snippet)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(snippet)
|
||||||
|
original = await s.get(Note, family["note"])
|
||||||
|
assert original.note_type == "note"
|
||||||
|
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||||
|
AsyncMock(return_value=[(0.86, original)])):
|
||||||
|
assert await family_svc.repeat_trigger(family["owner"], snippet) is None
|
||||||
|
assert await _idea(family["note"]) is None
|
||||||
|
|
||||||
|
|
||||||
async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family):
|
async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family):
|
||||||
async with async_session() as s:
|
async with async_session() as s:
|
||||||
on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1")
|
on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1")
|
||||||
|
|||||||
Reference in New Issue
Block a user