algo-cli-runtime 0.14.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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- ollama_cli/__init__.py +67 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""B89/B99. Reconciliation triage and bank matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from itertools import combinations
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from .common import RiskLevel, SourceRef, decimalize, stable_exception_id, within_tolerance
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ReconciliationAccount:
|
|
16
|
+
account: str
|
|
17
|
+
balance: Decimal | int | str
|
|
18
|
+
last_reconciled: date | None = None
|
|
19
|
+
unreconciled_items: int = 0
|
|
20
|
+
risk_level: RiskLevel = RiskLevel.MEDIUM
|
|
21
|
+
owner: str | None = None
|
|
22
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
object.__setattr__(self, "balance", decimalize(self.balance))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RiskScore:
|
|
30
|
+
account: str
|
|
31
|
+
score: Decimal
|
|
32
|
+
level: RiskLevel
|
|
33
|
+
reasons: list[str]
|
|
34
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ReconciliationTriage:
|
|
38
|
+
"""Rank balance-sheet reconciliations by materiality, age, and risk."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, materiality: Decimal | int | str = Decimal("1000"), as_of: date | None = None) -> None:
|
|
41
|
+
self.materiality = decimalize(materiality)
|
|
42
|
+
self.as_of = as_of or date.today()
|
|
43
|
+
|
|
44
|
+
def score(self, account: ReconciliationAccount) -> RiskScore:
|
|
45
|
+
amount_score = Decimal("0") if self.materiality == 0 else min(abs(account.balance) / self.materiality, Decimal("10")) * 100
|
|
46
|
+
age_score = Decimal("0")
|
|
47
|
+
reasons: list[str] = []
|
|
48
|
+
if account.last_reconciled is None:
|
|
49
|
+
age_score = Decimal("500")
|
|
50
|
+
reasons.append("never reconciled")
|
|
51
|
+
else:
|
|
52
|
+
days = (self.as_of - account.last_reconciled).days
|
|
53
|
+
if days > 60:
|
|
54
|
+
age_score = Decimal("300")
|
|
55
|
+
reasons.append("stale >60 days")
|
|
56
|
+
elif days > 30:
|
|
57
|
+
age_score = Decimal("150")
|
|
58
|
+
reasons.append("stale >30 days")
|
|
59
|
+
item_score = Decimal(account.unreconciled_items) * Decimal("25")
|
|
60
|
+
if account.unreconciled_items:
|
|
61
|
+
reasons.append(f"{account.unreconciled_items} unreconciled items")
|
|
62
|
+
risk_score = Decimal(int(account.risk_level)) * Decimal("250")
|
|
63
|
+
score = amount_score + age_score + item_score + risk_score
|
|
64
|
+
level = RiskLevel.CRITICAL if score >= 1200 else RiskLevel.HIGH if score >= 800 else RiskLevel.MEDIUM if score >= 400 else RiskLevel.LOW
|
|
65
|
+
return RiskScore(account=account.account, score=score, level=level, reasons=reasons, source_refs=list(account.source_refs))
|
|
66
|
+
|
|
67
|
+
def prioritize(self, accounts: Iterable[ReconciliationAccount]) -> list[ReconciliationAccount]:
|
|
68
|
+
return sorted(accounts, key=lambda account: (self.score(account).score, abs(account.balance), account.account), reverse=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class BankItem:
|
|
73
|
+
id: str
|
|
74
|
+
amount: Decimal | int | str
|
|
75
|
+
date: date | str
|
|
76
|
+
description: str = ""
|
|
77
|
+
reference: str | None = None
|
|
78
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def __post_init__(self) -> None:
|
|
81
|
+
object.__setattr__(self, "amount", decimalize(self.amount))
|
|
82
|
+
if isinstance(self.date, str):
|
|
83
|
+
object.__setattr__(self, "date", datetime.fromisoformat(self.date[:10]).date())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class BankMatch:
|
|
88
|
+
id: str
|
|
89
|
+
book_ids: list[str]
|
|
90
|
+
bank_ids: list[str]
|
|
91
|
+
amount_difference: Decimal
|
|
92
|
+
date_difference_days: int
|
|
93
|
+
score: Decimal
|
|
94
|
+
match_type: str
|
|
95
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class MatchResult:
|
|
100
|
+
matches: list[BankMatch]
|
|
101
|
+
unmatched_book: list[BankItem]
|
|
102
|
+
unmatched_bank: list[BankItem]
|
|
103
|
+
tolerance_used: Decimal
|
|
104
|
+
date_window_days: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BankMatcher:
|
|
108
|
+
"""Deterministic bank reconciliation matcher.
|
|
109
|
+
|
|
110
|
+
Uses exact/reference matches first, greedy one-to-one fuzzy matching second,
|
|
111
|
+
and bounded subset matching for small many-to-one receipt/disbursement groups.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def match(
|
|
115
|
+
self,
|
|
116
|
+
book_items: Iterable[BankItem | dict[str, Any]],
|
|
117
|
+
bank_items: Iterable[BankItem | dict[str, Any]],
|
|
118
|
+
tolerance: Decimal | int | str = Decimal("0"),
|
|
119
|
+
date_window_days: int = 3,
|
|
120
|
+
subset_size: int = 3,
|
|
121
|
+
) -> MatchResult:
|
|
122
|
+
tolerance = decimalize(tolerance)
|
|
123
|
+
book = [_coerce_item(item) for item in book_items]
|
|
124
|
+
bank = [_coerce_item(item) for item in bank_items]
|
|
125
|
+
matched_book: set[str] = set()
|
|
126
|
+
matched_bank: set[str] = set()
|
|
127
|
+
matches: list[BankMatch] = []
|
|
128
|
+
|
|
129
|
+
# Pass 1: exact amount/date/reference.
|
|
130
|
+
for b in book:
|
|
131
|
+
for k in bank:
|
|
132
|
+
if b.id in matched_book or k.id in matched_bank:
|
|
133
|
+
continue
|
|
134
|
+
if _reference_equal(b.reference, k.reference) and b.date == k.date and within_tolerance(b.amount, k.amount, tolerance):
|
|
135
|
+
matches.append(_make_match([b], [k], "exact-reference"))
|
|
136
|
+
matched_book.add(b.id)
|
|
137
|
+
matched_bank.add(k.id)
|
|
138
|
+
|
|
139
|
+
# Pass 2: one-to-one fuzzy amount/date/description.
|
|
140
|
+
candidates: list[tuple[Decimal, BankItem, BankItem]] = []
|
|
141
|
+
for b in book:
|
|
142
|
+
if b.id in matched_book:
|
|
143
|
+
continue
|
|
144
|
+
for k in bank:
|
|
145
|
+
if k.id in matched_bank:
|
|
146
|
+
continue
|
|
147
|
+
days = abs((b.date - k.date).days)
|
|
148
|
+
if days <= date_window_days and within_tolerance(b.amount, k.amount, tolerance):
|
|
149
|
+
amount_diff = abs(b.amount - k.amount)
|
|
150
|
+
score = amount_diff + Decimal(days) / Decimal("100") - (_description_overlap(b.description, k.description) / Decimal("1000"))
|
|
151
|
+
candidates.append((score, b, k))
|
|
152
|
+
for _score, b, k in sorted(candidates, key=lambda row: (row[0], row[1].id, row[2].id)):
|
|
153
|
+
if b.id in matched_book or k.id in matched_bank:
|
|
154
|
+
continue
|
|
155
|
+
matches.append(_make_match([b], [k], "fuzzy-one-to-one"))
|
|
156
|
+
matched_book.add(b.id)
|
|
157
|
+
matched_bank.add(k.id)
|
|
158
|
+
|
|
159
|
+
# Pass 3: bounded many-to-one/one-to-many subset matching.
|
|
160
|
+
remaining_book = [b for b in book if b.id not in matched_book]
|
|
161
|
+
remaining_bank = [k for k in bank if k.id not in matched_bank]
|
|
162
|
+
subset_matches = self._subset_matches(remaining_book, remaining_bank, tolerance, date_window_days, subset_size)
|
|
163
|
+
for match in subset_matches:
|
|
164
|
+
if any(book_id in matched_book for book_id in match.book_ids) or any(bank_id in matched_bank for bank_id in match.bank_ids):
|
|
165
|
+
continue
|
|
166
|
+
matches.append(match)
|
|
167
|
+
matched_book.update(match.book_ids)
|
|
168
|
+
matched_bank.update(match.bank_ids)
|
|
169
|
+
|
|
170
|
+
return MatchResult(
|
|
171
|
+
matches=sorted(matches, key=lambda m: (m.match_type, m.id)),
|
|
172
|
+
unmatched_book=[b for b in book if b.id not in matched_book],
|
|
173
|
+
unmatched_bank=[k for k in bank if k.id not in matched_bank],
|
|
174
|
+
tolerance_used=tolerance,
|
|
175
|
+
date_window_days=date_window_days,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _subset_matches(
|
|
179
|
+
self,
|
|
180
|
+
book: list[BankItem],
|
|
181
|
+
bank: list[BankItem],
|
|
182
|
+
tolerance: Decimal,
|
|
183
|
+
date_window_days: int,
|
|
184
|
+
subset_size: int,
|
|
185
|
+
) -> list[BankMatch]:
|
|
186
|
+
out: list[BankMatch] = []
|
|
187
|
+
# many book to one bank
|
|
188
|
+
for size in range(2, min(subset_size, len(book)) + 1):
|
|
189
|
+
for group in combinations(book, size):
|
|
190
|
+
amount = sum((item.amount for item in group), Decimal("0"))
|
|
191
|
+
latest = max(item.date for item in group)
|
|
192
|
+
earliest = min(item.date for item in group)
|
|
193
|
+
for bank_item in bank:
|
|
194
|
+
if within_tolerance(amount, bank_item.amount, tolerance) and min(
|
|
195
|
+
abs((latest - bank_item.date).days), abs((earliest - bank_item.date).days)
|
|
196
|
+
) <= date_window_days:
|
|
197
|
+
out.append(_make_match(list(group), [bank_item], "many-book-to-one-bank"))
|
|
198
|
+
# one book to many bank
|
|
199
|
+
for size in range(2, min(subset_size, len(bank)) + 1):
|
|
200
|
+
for group in combinations(bank, size):
|
|
201
|
+
amount = sum((item.amount for item in group), Decimal("0"))
|
|
202
|
+
latest = max(item.date for item in group)
|
|
203
|
+
earliest = min(item.date for item in group)
|
|
204
|
+
for book_item in book:
|
|
205
|
+
if within_tolerance(book_item.amount, amount, tolerance) and min(
|
|
206
|
+
abs((latest - book_item.date).days), abs((earliest - book_item.date).days)
|
|
207
|
+
) <= date_window_days:
|
|
208
|
+
out.append(_make_match([book_item], list(group), "one-book-to-many-bank"))
|
|
209
|
+
return sorted(out, key=lambda m: (abs(m.amount_difference), m.date_difference_days, m.id))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _coerce_item(item: BankItem | dict[str, Any]) -> BankItem:
|
|
213
|
+
if isinstance(item, BankItem):
|
|
214
|
+
return item
|
|
215
|
+
return BankItem(
|
|
216
|
+
id=str(item["id"]),
|
|
217
|
+
amount=item.get("amount", 0),
|
|
218
|
+
date=item.get("date"),
|
|
219
|
+
description=str(item.get("description", "")),
|
|
220
|
+
reference=item.get("reference"),
|
|
221
|
+
source_refs=list(item.get("source_refs", [])),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _reference_equal(left: str | None, right: str | None) -> bool:
|
|
226
|
+
if not left or not right:
|
|
227
|
+
return False
|
|
228
|
+
return "".join(str(left).lower().split()) == "".join(str(right).lower().split())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _description_overlap(left: str, right: str) -> Decimal:
|
|
232
|
+
a = {token for token in left.lower().split() if len(token) > 2}
|
|
233
|
+
b = {token for token in right.lower().split() if len(token) > 2}
|
|
234
|
+
if not a or not b:
|
|
235
|
+
return Decimal("0")
|
|
236
|
+
return Decimal(len(a & b)) / Decimal(len(a | b))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _make_match(book: list[BankItem], bank: list[BankItem], match_type: str) -> BankMatch:
|
|
240
|
+
book_amount = sum((item.amount for item in book), Decimal("0"))
|
|
241
|
+
bank_amount = sum((item.amount for item in bank), Decimal("0"))
|
|
242
|
+
book_date = max(item.date for item in book)
|
|
243
|
+
bank_date = max(item.date for item in bank)
|
|
244
|
+
refs = [ref for item in [*book, *bank] for ref in item.source_refs]
|
|
245
|
+
return BankMatch(
|
|
246
|
+
id=stable_exception_id("B99", [sorted(item.id for item in book), sorted(item.id for item in bank), match_type]),
|
|
247
|
+
book_ids=[item.id for item in book],
|
|
248
|
+
bank_ids=[item.id for item in bank],
|
|
249
|
+
amount_difference=book_amount - bank_amount,
|
|
250
|
+
date_difference_days=abs((book_date - bank_date).days),
|
|
251
|
+
score=abs(book_amount - bank_amount) + Decimal(abs((book_date - bank_date).days)) / Decimal("100"),
|
|
252
|
+
match_type=match_type,
|
|
253
|
+
source_refs=refs,
|
|
254
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""B95. ASC 606 Revenue Recognition Decision DAG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .common import ReviewGate, SourceRef, decimalize, stable_exception_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class PerformanceObligation:
|
|
14
|
+
id: str
|
|
15
|
+
description: str
|
|
16
|
+
transaction_price: Decimal | int | str
|
|
17
|
+
satisfied: bool = False
|
|
18
|
+
measure: str | None = None
|
|
19
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
object.__setattr__(self, "transaction_price", decimalize(self.transaction_price))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RevenueContract:
|
|
27
|
+
id: str
|
|
28
|
+
customer: str
|
|
29
|
+
approved: bool
|
|
30
|
+
rights_identified: bool
|
|
31
|
+
payment_terms_identified: bool
|
|
32
|
+
commercial_substance: bool
|
|
33
|
+
collectability_probable: bool
|
|
34
|
+
obligations: list[PerformanceObligation]
|
|
35
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RevenueDecision:
|
|
40
|
+
contract_id: str
|
|
41
|
+
recognized_amount: Decimal
|
|
42
|
+
review_required: bool
|
|
43
|
+
reasons: list[str]
|
|
44
|
+
review_gate: ReviewGate
|
|
45
|
+
source_refs: list[SourceRef]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ASC606DecisionDAG:
|
|
49
|
+
"""Five-step ASC 606 decision support, not accounting sign-off."""
|
|
50
|
+
|
|
51
|
+
def evaluate(self, contract: RevenueContract | dict[str, Any]) -> RevenueDecision:
|
|
52
|
+
c = _coerce_contract(contract)
|
|
53
|
+
reasons: list[str] = []
|
|
54
|
+
criteria = {
|
|
55
|
+
"contract not approved": c.approved,
|
|
56
|
+
"rights not identified": c.rights_identified,
|
|
57
|
+
"payment terms not identified": c.payment_terms_identified,
|
|
58
|
+
"commercial substance missing": c.commercial_substance,
|
|
59
|
+
"collectability not probable": c.collectability_probable,
|
|
60
|
+
}
|
|
61
|
+
for reason, ok in criteria.items():
|
|
62
|
+
if not ok:
|
|
63
|
+
reasons.append(reason)
|
|
64
|
+
if not c.obligations:
|
|
65
|
+
reasons.append("missing performance obligations")
|
|
66
|
+
source_refs = list(c.source_refs)
|
|
67
|
+
for obligation in c.obligations:
|
|
68
|
+
source_refs.extend(obligation.source_refs)
|
|
69
|
+
if not obligation.source_refs:
|
|
70
|
+
reasons.append(f"performance obligation {obligation.id} lacks source evidence")
|
|
71
|
+
recognized = sum((ob.transaction_price for ob in c.obligations if ob.satisfied and ob.source_refs), Decimal("0"))
|
|
72
|
+
review_required = bool(reasons)
|
|
73
|
+
return RevenueDecision(
|
|
74
|
+
contract_id=c.id,
|
|
75
|
+
recognized_amount=recognized if not reasons else Decimal("0") if any("contract" in r or "collectability" in r for r in reasons) else recognized,
|
|
76
|
+
review_required=review_required,
|
|
77
|
+
reasons=reasons,
|
|
78
|
+
review_gate=ReviewGate.CPA_REVIEW if review_required else ReviewGate.CONTROLLER_REVIEW,
|
|
79
|
+
source_refs=source_refs,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _coerce_contract(contract: RevenueContract | dict[str, Any]) -> RevenueContract:
|
|
84
|
+
if isinstance(contract, RevenueContract):
|
|
85
|
+
return contract
|
|
86
|
+
obligations = []
|
|
87
|
+
for row in contract.get("obligations", []):
|
|
88
|
+
if isinstance(row, PerformanceObligation):
|
|
89
|
+
obligations.append(row)
|
|
90
|
+
else:
|
|
91
|
+
obligations.append(PerformanceObligation(
|
|
92
|
+
id=str(row.get("id") or stable_exception_id("B95", [row.get("description"), row.get("transaction_price")])) ,
|
|
93
|
+
description=str(row.get("description", "")),
|
|
94
|
+
transaction_price=row.get("transaction_price", 0),
|
|
95
|
+
satisfied=bool(row.get("satisfied", False)),
|
|
96
|
+
measure=row.get("measure"),
|
|
97
|
+
source_refs=list(row.get("source_refs", [])),
|
|
98
|
+
))
|
|
99
|
+
return RevenueContract(
|
|
100
|
+
id=str(contract.get("id", "")),
|
|
101
|
+
customer=str(contract.get("customer", "")),
|
|
102
|
+
approved=bool(contract.get("approved", False)),
|
|
103
|
+
rights_identified=bool(contract.get("rights_identified", False)),
|
|
104
|
+
payment_terms_identified=bool(contract.get("payment_terms_identified", False)),
|
|
105
|
+
commercial_substance=bool(contract.get("commercial_substance", False)),
|
|
106
|
+
collectability_probable=bool(contract.get("collectability_probable", False)),
|
|
107
|
+
obligations=obligations,
|
|
108
|
+
source_refs=list(contract.get("source_refs", [])),
|
|
109
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""B102. Sales/Use Tax and Invoice Taxability Classifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
from .common import ReviewGate, SourceRef, decimalize, stable_exception_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class TaxPolicy:
|
|
14
|
+
id: str
|
|
15
|
+
taxable_categories: set[str]
|
|
16
|
+
rate: Decimal | int | str
|
|
17
|
+
exempt_categories: set[str] = field(default_factory=set)
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
object.__setattr__(self, "rate", decimalize(self.rate))
|
|
21
|
+
object.__setattr__(self, "taxable_categories", {cat.lower() for cat in self.taxable_categories})
|
|
22
|
+
object.__setattr__(self, "exempt_categories", {cat.lower() for cat in self.exempt_categories})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class TaxLineResult:
|
|
27
|
+
line_id: str
|
|
28
|
+
category: str
|
|
29
|
+
amount: Decimal
|
|
30
|
+
taxable: bool
|
|
31
|
+
tax: Decimal
|
|
32
|
+
reason: str
|
|
33
|
+
source_refs: list[SourceRef]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class TaxClassification:
|
|
38
|
+
policy_id: str
|
|
39
|
+
taxable_amount: Decimal
|
|
40
|
+
tax_amount: Decimal
|
|
41
|
+
line_results: list[TaxLineResult]
|
|
42
|
+
review_gate: ReviewGate = ReviewGate.CONTROLLER_REVIEW
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SalesUseTaxClassifier:
|
|
46
|
+
def classify_invoice(self, lines: Iterable[dict[str, Any]], jurisdiction_policy: TaxPolicy | dict[str, Any]) -> TaxClassification:
|
|
47
|
+
policy = _coerce_policy(jurisdiction_policy)
|
|
48
|
+
results: list[TaxLineResult] = []
|
|
49
|
+
for idx, line in enumerate(lines, start=1):
|
|
50
|
+
category = str(line.get("category", "")).lower()
|
|
51
|
+
amount = decimalize(line.get("amount", 0))
|
|
52
|
+
line_id = str(line.get("id") or stable_exception_id("B102", [idx, category, amount]))
|
|
53
|
+
if category in policy.exempt_categories:
|
|
54
|
+
taxable = False
|
|
55
|
+
reason = "category explicitly exempt"
|
|
56
|
+
else:
|
|
57
|
+
taxable = category in policy.taxable_categories
|
|
58
|
+
reason = "category taxable" if taxable else "category not taxable under policy"
|
|
59
|
+
tax = amount * policy.rate if taxable else Decimal("0")
|
|
60
|
+
results.append(TaxLineResult(line_id, category, amount, taxable, tax, reason, list(line.get("source_refs", []))))
|
|
61
|
+
taxable_amount = sum((row.amount for row in results if row.taxable), Decimal("0"))
|
|
62
|
+
tax_amount = sum((row.tax for row in results), Decimal("0"))
|
|
63
|
+
return TaxClassification(policy.id, taxable_amount, tax_amount, results)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _coerce_policy(policy: TaxPolicy | dict[str, Any]) -> TaxPolicy:
|
|
67
|
+
if isinstance(policy, TaxPolicy):
|
|
68
|
+
return policy
|
|
69
|
+
return TaxPolicy(
|
|
70
|
+
id=str(policy.get("id", "policy")),
|
|
71
|
+
taxable_categories=set(policy.get("taxable_categories", [])),
|
|
72
|
+
exempt_categories=set(policy.get("exempt_categories", [])),
|
|
73
|
+
rate=policy.get("rate", 0),
|
|
74
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""B104. CPA Workpaper Tie-Out and Crossfoot Rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
from .common import ReviewGate, SourceRef, decimalize, within_tolerance
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TieOutResult:
|
|
14
|
+
clean: bool
|
|
15
|
+
check_type: str
|
|
16
|
+
expected: Decimal
|
|
17
|
+
actual: Decimal
|
|
18
|
+
difference: Decimal
|
|
19
|
+
tolerance_used: Decimal
|
|
20
|
+
message: str
|
|
21
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
22
|
+
review_gate: ReviewGate = ReviewGate.CONTROLLER_REVIEW
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WorkpaperTieOut:
|
|
26
|
+
"""Foot, crossfoot, trace, and sign-convention checks for schedules."""
|
|
27
|
+
|
|
28
|
+
def foot(
|
|
29
|
+
self,
|
|
30
|
+
table: Iterable[dict[str, Any]],
|
|
31
|
+
columns: Iterable[str],
|
|
32
|
+
expected_totals: dict[str, Any] | None = None,
|
|
33
|
+
tolerance: Any = Decimal("0"),
|
|
34
|
+
) -> dict[str, TieOutResult]:
|
|
35
|
+
rows = list(table)
|
|
36
|
+
results: dict[str, TieOutResult] = {}
|
|
37
|
+
for column in columns:
|
|
38
|
+
actual = sum((decimalize(row.get(column, 0)) for row in rows), Decimal("0"))
|
|
39
|
+
expected = decimalize((expected_totals or {}).get(column, actual))
|
|
40
|
+
difference = actual - expected
|
|
41
|
+
clean = within_tolerance(actual, expected, tolerance)
|
|
42
|
+
results[column] = TieOutResult(
|
|
43
|
+
clean=clean,
|
|
44
|
+
check_type="foot",
|
|
45
|
+
expected=expected,
|
|
46
|
+
actual=actual,
|
|
47
|
+
difference=difference,
|
|
48
|
+
tolerance_used=decimalize(tolerance),
|
|
49
|
+
message="Foots" if clean else f"Column {column} does not foot",
|
|
50
|
+
)
|
|
51
|
+
return results
|
|
52
|
+
|
|
53
|
+
def crossfoot(
|
|
54
|
+
self,
|
|
55
|
+
table: Iterable[dict[str, Any]],
|
|
56
|
+
row_total_col: str,
|
|
57
|
+
component_cols: Iterable[str],
|
|
58
|
+
tolerance: Any = Decimal("0"),
|
|
59
|
+
) -> list[TieOutResult]:
|
|
60
|
+
results: list[TieOutResult] = []
|
|
61
|
+
comps = list(component_cols)
|
|
62
|
+
for index, row in enumerate(table, start=1):
|
|
63
|
+
expected = sum((decimalize(row.get(col, 0)) for col in comps), Decimal("0"))
|
|
64
|
+
actual = decimalize(row.get(row_total_col, 0))
|
|
65
|
+
clean = within_tolerance(actual, expected, tolerance)
|
|
66
|
+
results.append(TieOutResult(
|
|
67
|
+
clean=clean,
|
|
68
|
+
check_type="crossfoot",
|
|
69
|
+
expected=expected,
|
|
70
|
+
actual=actual,
|
|
71
|
+
difference=actual - expected,
|
|
72
|
+
tolerance_used=decimalize(tolerance),
|
|
73
|
+
message="Crossfoots" if clean else f"Row {index} does not crossfoot",
|
|
74
|
+
))
|
|
75
|
+
return results
|
|
76
|
+
|
|
77
|
+
def trace_to_source(
|
|
78
|
+
self,
|
|
79
|
+
schedule_total: Any,
|
|
80
|
+
source_total: Any,
|
|
81
|
+
tolerance: Any = Decimal("0"),
|
|
82
|
+
source_refs: Iterable[SourceRef] = (),
|
|
83
|
+
) -> TieOutResult:
|
|
84
|
+
actual = decimalize(schedule_total)
|
|
85
|
+
expected = decimalize(source_total)
|
|
86
|
+
clean = within_tolerance(actual, expected, tolerance)
|
|
87
|
+
return TieOutResult(
|
|
88
|
+
clean=clean,
|
|
89
|
+
check_type="trace",
|
|
90
|
+
expected=expected,
|
|
91
|
+
actual=actual,
|
|
92
|
+
difference=actual - expected,
|
|
93
|
+
tolerance_used=decimalize(tolerance),
|
|
94
|
+
message="Ties to source" if clean else "Schedule total does not tie to source",
|
|
95
|
+
source_refs=list(source_refs),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def sign_convention_check(self, amount: Any, expected_sign: str, mapped: bool = False) -> TieOutResult:
|
|
99
|
+
actual = decimalize(amount)
|
|
100
|
+
expected_positive = expected_sign.lower() in {"positive", "+", "debit"}
|
|
101
|
+
sign_ok = actual >= 0 if expected_positive else actual <= 0
|
|
102
|
+
clean = sign_ok or mapped
|
|
103
|
+
return TieOutResult(
|
|
104
|
+
clean=clean,
|
|
105
|
+
check_type="sign_convention",
|
|
106
|
+
expected=Decimal("1") if expected_positive else Decimal("-1"),
|
|
107
|
+
actual=Decimal("1") if actual >= 0 else Decimal("-1"),
|
|
108
|
+
difference=Decimal("0") if clean else actual,
|
|
109
|
+
tolerance_used=Decimal("0"),
|
|
110
|
+
message="Sign convention explicit" if clean else "Sign flip requires explicit mapping",
|
|
111
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""H1 — Algorithm Finding Record.
|
|
2
|
+
|
|
3
|
+
Structured append-only findings with provenance, mined from T3MP3ST Evidence Vault §8.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FindingStatus(str, Enum):
|
|
14
|
+
PROPOSED = "proposed"
|
|
15
|
+
VALIDATED = "validated"
|
|
16
|
+
RETRACTED = "retracted"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Finding:
|
|
21
|
+
"""A single algorithm finding with provenance."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
title: str
|
|
25
|
+
description: str
|
|
26
|
+
source_repo: str
|
|
27
|
+
source_section: str
|
|
28
|
+
status: FindingStatus = FindingStatus.PROPOSED
|
|
29
|
+
created_at: float = field(default_factory=time.time)
|
|
30
|
+
provenance: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
evidence: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"id": self.id,
|
|
36
|
+
"title": self.title,
|
|
37
|
+
"description": self.description,
|
|
38
|
+
"source_repo": self.source_repo,
|
|
39
|
+
"source_section": self.source_section,
|
|
40
|
+
"status": self.status.value,
|
|
41
|
+
"created_at": self.created_at,
|
|
42
|
+
"provenance": dict(self.provenance),
|
|
43
|
+
"evidence": list(self.evidence),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict[str, Any]) -> Finding:
|
|
48
|
+
return cls(
|
|
49
|
+
id=data["id"],
|
|
50
|
+
title=data["title"],
|
|
51
|
+
description=data["description"],
|
|
52
|
+
source_repo=data["source_repo"],
|
|
53
|
+
source_section=data["source_section"],
|
|
54
|
+
status=FindingStatus(data.get("status", "proposed")),
|
|
55
|
+
created_at=data.get("created_at", time.time()),
|
|
56
|
+
provenance=dict(data.get("provenance", {})),
|
|
57
|
+
evidence=list(data.get("evidence", [])),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FindingRecord:
|
|
62
|
+
"""Append-only store for algorithm findings."""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self._findings: list[Finding] = []
|
|
66
|
+
|
|
67
|
+
def create(
|
|
68
|
+
self,
|
|
69
|
+
id: str,
|
|
70
|
+
title: str,
|
|
71
|
+
description: str,
|
|
72
|
+
source_repo: str,
|
|
73
|
+
source_section: str,
|
|
74
|
+
provenance: dict[str, Any] | None = None,
|
|
75
|
+
evidence: list[str] | None = None,
|
|
76
|
+
) -> Finding:
|
|
77
|
+
if self.get(id) is not None:
|
|
78
|
+
raise ValueError(f"Finding {id!r} already exists — append-only store")
|
|
79
|
+
finding = Finding(
|
|
80
|
+
id=id,
|
|
81
|
+
title=title,
|
|
82
|
+
description=description,
|
|
83
|
+
source_repo=source_repo,
|
|
84
|
+
source_section=source_section,
|
|
85
|
+
provenance=provenance or {},
|
|
86
|
+
evidence=evidence or [],
|
|
87
|
+
)
|
|
88
|
+
self._findings.append(finding)
|
|
89
|
+
return finding
|
|
90
|
+
|
|
91
|
+
def get(self, id: str) -> Finding | None:
|
|
92
|
+
for f in self._findings:
|
|
93
|
+
if f.id == id:
|
|
94
|
+
return f
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def query(
|
|
98
|
+
self,
|
|
99
|
+
status: FindingStatus | None = None,
|
|
100
|
+
source_repo: str | None = None,
|
|
101
|
+
) -> list[Finding]:
|
|
102
|
+
results = self._findings
|
|
103
|
+
if status is not None:
|
|
104
|
+
results = [f for f in results if f.status == status]
|
|
105
|
+
if source_repo is not None:
|
|
106
|
+
results = [f for f in results if f.source_repo == source_repo]
|
|
107
|
+
return list(results)
|
|
108
|
+
|
|
109
|
+
def update_status(self, id: str, status: FindingStatus) -> Finding:
|
|
110
|
+
f = self.get(id)
|
|
111
|
+
if f is None:
|
|
112
|
+
raise KeyError(f"Finding {id!r} not found")
|
|
113
|
+
f.status = status
|
|
114
|
+
return f
|
|
115
|
+
|
|
116
|
+
def all(self) -> list[Finding]:
|
|
117
|
+
return list(self._findings)
|
|
118
|
+
|
|
119
|
+
def count(self) -> int:
|
|
120
|
+
return len(self._findings)
|