amfs-adapter-postgres 0.2.40__tar.gz → 0.2.42__tar.gz
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.
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/PKG-INFO +2 -2
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/pyproject.toml +2 -2
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/adapter.py +400 -27
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/.gitignore +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/__init__.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/_fts.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/async_adapter.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/evidence_sql.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/knn.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/001_initial.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/002_artifact_refs.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/002_search_indexes.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/003_knowledge_graph.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/004_agent_groups.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/005_configurable_embedding_dim.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/006_is_artifact.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/006_outcome_confidence_semantics.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/007_outcome_propagation_row_copy.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/008_outcome_evidence.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/009_action_priors.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/010_local_evidence.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/011_outcome_task_text.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/012_evidence_from_model.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/013_outcome_verified_by_backfill.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/retention.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/schema.sql +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/tenant_context.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/tenant_gucs.py +0 -0
- {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/trace_partitions.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: amfs-adapter-postgres
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.42
|
|
4
4
|
Summary: AMFS Postgres adapter with back-propagation triggers
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
|
-
Requires-Dist: amfs-core>=0.3.
|
|
7
|
+
Requires-Dist: amfs-core>=0.3.53
|
|
8
8
|
Requires-Dist: psycopg-pool>=3.1
|
|
9
9
|
Requires-Dist: psycopg[binary]<4,>=3.1
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "amfs-adapter-postgres"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.42"
|
|
4
4
|
description = "AMFS Postgres adapter with back-propagation triggers"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
license = "Apache-2.0"
|
|
7
7
|
dependencies = [
|
|
8
8
|
# 0.3.14 adds the capture fields this adapter now persists and selects back,
|
|
9
9
|
# tool_calls among them.
|
|
10
|
-
"amfs-core>=0.3.
|
|
10
|
+
"amfs-core>=0.3.53",
|
|
11
11
|
"psycopg[binary]>=3.1,<4",
|
|
12
12
|
"psycopg-pool>=3.1",
|
|
13
13
|
]
|
|
@@ -183,6 +183,45 @@ def _paginate_sql(
|
|
|
183
183
|
return query, params
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def priority_score_sql(now: datetime, config: Any | None = None) -> tuple[str, list[Any]]:
|
|
187
|
+
"""``amfs_core.tiering.PriorityScorer.score`` as a SQL expression.
|
|
188
|
+
|
|
189
|
+
The tiers pages rank every current entry in the account by this score and
|
|
190
|
+
take the top 50 as Hot and the next 200 as Warm. Scoring in Python meant
|
|
191
|
+
loading the whole namespace first; on a 420K-entry account the two
|
|
192
|
+
requests the page makes together took an 8 GiB instance down. The
|
|
193
|
+
expression below is the same formula over the same columns, so the
|
|
194
|
+
database can order by it and hand back one page::
|
|
195
|
+
|
|
196
|
+
(alpha * importance + beta * recency) * freq_boost * time_decay
|
|
197
|
+
|
|
198
|
+
with ``importance = importance_score or confidence`` clamped to [0, 1],
|
|
199
|
+
``recency = exp(-lambda * age_days)``, ``freq_boost = max(1, ln(1 +
|
|
200
|
+
recall_count))`` and ``time_decay = exp(-lambda * age_days / freq_boost)``.
|
|
201
|
+
``importance_score`` of 0 falls through to confidence exactly as the
|
|
202
|
+
Python ``or`` does. Returns the expression and its parameters in placeholder
|
|
203
|
+
order: alpha, beta, lambda, *now*, lambda, *now*.
|
|
204
|
+
"""
|
|
205
|
+
if config is None:
|
|
206
|
+
from amfs_core.models import TierConfig
|
|
207
|
+
|
|
208
|
+
config = TierConfig()
|
|
209
|
+
age_days = "GREATEST(0.0, EXTRACT(EPOCH FROM (%s - written_at)) / 86400.0)"
|
|
210
|
+
importance = (
|
|
211
|
+
"LEAST(1.0, GREATEST(0.0, COALESCE(NULLIF(importance_score, 0), confidence)::float8))"
|
|
212
|
+
)
|
|
213
|
+
freq_boost = "GREATEST(1.0, LN(1.0 + COALESCE(recall_count, 0)))"
|
|
214
|
+
expr = (
|
|
215
|
+
f"((%s * {importance} + %s * EXP(-%s * {age_days}))"
|
|
216
|
+
f" * {freq_boost}"
|
|
217
|
+
f" * EXP(-%s * {age_days} / {freq_boost}))"
|
|
218
|
+
)
|
|
219
|
+
# The age expression appears twice, each with its own ``now``.
|
|
220
|
+
lam = float(config.decay_lambda)
|
|
221
|
+
params: list[Any] = [float(config.alpha), float(config.beta), lam, now, lam, now]
|
|
222
|
+
return expr, params
|
|
223
|
+
|
|
224
|
+
|
|
186
225
|
def list_conditions(
|
|
187
226
|
namespace: str,
|
|
188
227
|
entity_path: str | None,
|
|
@@ -477,6 +516,7 @@ from amfs_core.aggregates import ( # noqa: E402
|
|
|
477
516
|
CHARS_PER_TOKEN as _CHARS_PER_TOKEN,
|
|
478
517
|
RECALL_TOKENS_CEIL as _RECALL_TOKENS_CEIL,
|
|
479
518
|
RECALL_TOKENS_FLOOR as _RECALL_TOKENS_FLOOR,
|
|
519
|
+
entity_confidence_buckets,
|
|
480
520
|
)
|
|
481
521
|
|
|
482
522
|
# Guarded by recall_count, because an entry never recalled contributes zero
|
|
@@ -2499,6 +2539,90 @@ class PostgresAdapter(AdapterABC):
|
|
|
2499
2539
|
row = cur.fetchone()
|
|
2500
2540
|
return int(row["n"]) if row else 0
|
|
2501
2541
|
|
|
2542
|
+
def priority_stats(
|
|
2543
|
+
self,
|
|
2544
|
+
*,
|
|
2545
|
+
scope: SqlScope | None = None,
|
|
2546
|
+
agent_id: str | None = None,
|
|
2547
|
+
now: datetime | None = None,
|
|
2548
|
+
branch: str = "main",
|
|
2549
|
+
) -> tuple[int, float | None]:
|
|
2550
|
+
"""``(count, mean priority score)`` over the rows :meth:`list` returns.
|
|
2551
|
+
|
|
2552
|
+
What the tiers distribution needs and nothing more: the count fixes
|
|
2553
|
+
the Hot/Warm/Archive split (the tiers are rank bands) and the mean is
|
|
2554
|
+
the page's "average score". Computed by the database with
|
|
2555
|
+
:func:`priority_score_sql`; no entry row leaves it.
|
|
2556
|
+
"""
|
|
2557
|
+
now = now or datetime.now(timezone.utc)
|
|
2558
|
+
score, score_params = priority_score_sql(now)
|
|
2559
|
+
with self._pool.connection() as conn:
|
|
2560
|
+
with conn.cursor() as cur:
|
|
2561
|
+
conditions, params = list_conditions(
|
|
2562
|
+
self._namespace,
|
|
2563
|
+
None,
|
|
2564
|
+
include_superseded=False,
|
|
2565
|
+
branch=branch,
|
|
2566
|
+
scope=scope,
|
|
2567
|
+
agent_id=agent_id,
|
|
2568
|
+
branch_scope=self._branch_scope(cur, branch),
|
|
2569
|
+
)
|
|
2570
|
+
where = " AND ".join(conditions)
|
|
2571
|
+
cur.execute(
|
|
2572
|
+
f"SELECT COUNT(*) AS n, AVG({score}) AS mean "
|
|
2573
|
+
f"FROM amfs_memory_entries WHERE {where}",
|
|
2574
|
+
[*score_params, *params],
|
|
2575
|
+
)
|
|
2576
|
+
row = cur.fetchone()
|
|
2577
|
+
if not row or not row["n"]:
|
|
2578
|
+
return 0, None
|
|
2579
|
+
mean = row["mean"]
|
|
2580
|
+
return int(row["n"]), (float(mean) if mean is not None else None)
|
|
2581
|
+
|
|
2582
|
+
def list_by_priority(
|
|
2583
|
+
self,
|
|
2584
|
+
*,
|
|
2585
|
+
scope: SqlScope | None = None,
|
|
2586
|
+
agent_id: str | None = None,
|
|
2587
|
+
limit: int,
|
|
2588
|
+
offset: int = 0,
|
|
2589
|
+
now: datetime | None = None,
|
|
2590
|
+
branch: str = "main",
|
|
2591
|
+
) -> list[MemoryEntry]:
|
|
2592
|
+
"""One page of the rows :meth:`list` returns, highest priority first.
|
|
2593
|
+
|
|
2594
|
+
The order is :func:`priority_score_sql` descending with the stable
|
|
2595
|
+
``written_at DESC, entity_path, key`` tiebreak, so a page boundary is
|
|
2596
|
+
deterministic. A tier is a band of this ranking: rows 0–49 are Hot,
|
|
2597
|
+
50–249 Warm, the rest Archive — the caller picks *offset* and *limit*
|
|
2598
|
+
from the band and the database returns only that page.
|
|
2599
|
+
"""
|
|
2600
|
+
now = now or datetime.now(timezone.utc)
|
|
2601
|
+
score, score_params = priority_score_sql(now)
|
|
2602
|
+
with self._pool.connection() as conn:
|
|
2603
|
+
with conn.cursor() as cur:
|
|
2604
|
+
conditions, params = list_conditions(
|
|
2605
|
+
self._namespace,
|
|
2606
|
+
None,
|
|
2607
|
+
include_superseded=False,
|
|
2608
|
+
branch=branch,
|
|
2609
|
+
scope=scope,
|
|
2610
|
+
agent_id=agent_id,
|
|
2611
|
+
branch_scope=self._branch_scope(cur, branch),
|
|
2612
|
+
)
|
|
2613
|
+
where = " AND ".join(conditions)
|
|
2614
|
+
query = (
|
|
2615
|
+
f"SELECT {entry_select(self._has_is_artifact_col, self._has_validators_col)} "
|
|
2616
|
+
f"FROM amfs_memory_entries WHERE {where} "
|
|
2617
|
+
f"ORDER BY {score} DESC, written_at DESC, entity_path, key"
|
|
2618
|
+
)
|
|
2619
|
+
query, all_params = _paginate_sql(
|
|
2620
|
+
query, [*params, *score_params], limit=limit, offset=offset
|
|
2621
|
+
)
|
|
2622
|
+
cur.execute(query, all_params)
|
|
2623
|
+
rows = cur.fetchall()
|
|
2624
|
+
return [self._row_to_entry(r) for r in rows]
|
|
2625
|
+
|
|
2502
2626
|
def entry_authors(self, refs: list[tuple[str, str]]) -> dict[tuple[str, str], str]:
|
|
2503
2627
|
"""Who wrote each ``(entity_path, key)``, for the current version.
|
|
2504
2628
|
|
|
@@ -3434,17 +3558,40 @@ class PostgresAdapter(AdapterABC):
|
|
|
3434
3558
|
newest_entry_at=row["newest_entry_at"],
|
|
3435
3559
|
)
|
|
3436
3560
|
|
|
3561
|
+
#: ``sort`` values ``entity_summaries`` accepts, and the ORDER BY each is.
|
|
3562
|
+
_ENTITY_SUMMARY_SORTS: dict[str, str] = {
|
|
3563
|
+
"last_updated": "last_updated DESC NULLS LAST, entity_path",
|
|
3564
|
+
"entry_count": "entry_count DESC, entity_path",
|
|
3565
|
+
"total_recalls": "total_recalls DESC, entity_path",
|
|
3566
|
+
"avg_confidence": "avg_confidence DESC NULLS LAST, entity_path",
|
|
3567
|
+
"avg_confidence_asc": "avg_confidence ASC NULLS LAST, entity_path",
|
|
3568
|
+
"entity_path": "entity_path",
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3437
3571
|
def entity_summaries(
|
|
3438
3572
|
self,
|
|
3439
3573
|
*,
|
|
3440
3574
|
agent_ids: list[str] | None = None,
|
|
3441
3575
|
scope: SqlScope | None = None,
|
|
3576
|
+
q: str | None = None,
|
|
3577
|
+
agent: str | None = None,
|
|
3578
|
+
min_confidence: float | None = None,
|
|
3579
|
+
sort: str = "last_updated",
|
|
3580
|
+
limit: int | None = None,
|
|
3581
|
+
offset: int = 0,
|
|
3442
3582
|
) -> list[dict[str, Any]]:
|
|
3443
3583
|
"""Per-entity aggregates via GROUP BY — never loads entry values.
|
|
3444
3584
|
|
|
3445
3585
|
*scope* is a visibility predicate (room co-member rules and the like)
|
|
3446
3586
|
that ``agent_ids`` alone cannot express; with it, a per-user dashboard
|
|
3447
3587
|
gets this query instead of a Python pass over every entry.
|
|
3588
|
+
|
|
3589
|
+
*q* keeps the paths containing it (case-insensitive), *agent* the
|
|
3590
|
+
entities that agent has written to, *min_confidence* those whose mean
|
|
3591
|
+
confidence reaches it — the topics directory's filters, in SQL. With
|
|
3592
|
+
*limit* one page comes back and every row carries ``total``, the
|
|
3593
|
+
count before paging. Unpaged, the whole list is returned as before —
|
|
3594
|
+
1.6 MB on a 4,000-entity account, so a directory should page.
|
|
3448
3595
|
"""
|
|
3449
3596
|
conditions = ["namespace = %s", "branch = 'main'", "superseded_at IS NULL"]
|
|
3450
3597
|
params: list[Any] = [self._namespace]
|
|
@@ -3453,6 +3600,24 @@ class PostgresAdapter(AdapterABC):
|
|
|
3453
3600
|
params.append(list(agent_ids))
|
|
3454
3601
|
if scope is not None:
|
|
3455
3602
|
scope.apply(conditions, params)
|
|
3603
|
+
if q:
|
|
3604
|
+
pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
3605
|
+
conditions.append("entity_path ILIKE %s")
|
|
3606
|
+
params.append(pattern)
|
|
3607
|
+
having: list[str] = []
|
|
3608
|
+
having_params: list[Any] = []
|
|
3609
|
+
if agent:
|
|
3610
|
+
having.append("BOOL_OR(agent_id = %s)")
|
|
3611
|
+
having_params.append(agent)
|
|
3612
|
+
if min_confidence is not None:
|
|
3613
|
+
having.append("AVG(confidence) >= %s")
|
|
3614
|
+
having_params.append(float(min_confidence))
|
|
3615
|
+
if sort not in self._ENTITY_SUMMARY_SORTS:
|
|
3616
|
+
raise ValueError(f"unknown sort {sort!r}")
|
|
3617
|
+
order_by = self._ENTITY_SUMMARY_SORTS[sort]
|
|
3618
|
+
page = ""
|
|
3619
|
+
if limit is not None:
|
|
3620
|
+
page = "LIMIT %s OFFSET %s"
|
|
3456
3621
|
# No entity_path argument to this one: it is unscoped by construction,
|
|
3457
3622
|
# so shared namespaces are always excluded. It groups by entity_path,
|
|
3458
3623
|
# which would otherwise list a shared namespace's topics as though
|
|
@@ -3478,8 +3643,21 @@ class PostgresAdapter(AdapterABC):
|
|
|
3478
3643
|
FROM amfs_memory_entries
|
|
3479
3644
|
WHERE {where}
|
|
3480
3645
|
GROUP BY entity_path
|
|
3481
|
-
|
|
3646
|
+
{("HAVING " + " AND ".join(having)) if having else ""}
|
|
3482
3647
|
"""
|
|
3648
|
+
params.extend(having_params)
|
|
3649
|
+
if limit is not None:
|
|
3650
|
+
# Paged: the count before paging rides on every row (one window
|
|
3651
|
+
# over the grouped rows, no second statement).
|
|
3652
|
+
params.extend([int(limit), max(0, int(offset))])
|
|
3653
|
+
sql = f"""
|
|
3654
|
+
SELECT x.*, COUNT(*) OVER () AS total
|
|
3655
|
+
FROM ({sql}) x
|
|
3656
|
+
ORDER BY {order_by}
|
|
3657
|
+
{page}
|
|
3658
|
+
"""
|
|
3659
|
+
else:
|
|
3660
|
+
sql = f"SELECT * FROM ({sql}) x ORDER BY {order_by}"
|
|
3483
3661
|
with self._pool.connection() as conn:
|
|
3484
3662
|
with conn.cursor() as cur:
|
|
3485
3663
|
# The checkout is one transaction (see _TenantRLSConnection),
|
|
@@ -3491,6 +3669,7 @@ class PostgresAdapter(AdapterABC):
|
|
|
3491
3669
|
|
|
3492
3670
|
return [
|
|
3493
3671
|
{
|
|
3672
|
+
**({"total": int(r["total"])} if limit is not None else {}),
|
|
3494
3673
|
"entity_path": r["entity_path"],
|
|
3495
3674
|
"entry_count": r["entry_count"],
|
|
3496
3675
|
"avg_confidence": float(r["avg_confidence"] or 0),
|
|
@@ -3504,6 +3683,44 @@ class PostgresAdapter(AdapterABC):
|
|
|
3504
3683
|
for r in rows
|
|
3505
3684
|
]
|
|
3506
3685
|
|
|
3686
|
+
def consolidation_ready_entities(
|
|
3687
|
+
self,
|
|
3688
|
+
*,
|
|
3689
|
+
min_entries: int = 10,
|
|
3690
|
+
limit: int = 10,
|
|
3691
|
+
scope: SqlScope | None = None,
|
|
3692
|
+
) -> list[str]:
|
|
3693
|
+
"""Entity paths with at least *min_entries* live entries, largest first.
|
|
3694
|
+
|
|
3695
|
+
What the consolidation status card shows. Counted in SQL: the handler
|
|
3696
|
+
used to load every entry on the account into Python to count them per
|
|
3697
|
+
path — two to three minutes on 430K entries, for ten path names.
|
|
3698
|
+
"""
|
|
3699
|
+
conditions = ["namespace = %s", "branch = 'main'", "superseded_at IS NULL"]
|
|
3700
|
+
params: list[Any] = [self._namespace]
|
|
3701
|
+
if scope is not None:
|
|
3702
|
+
scope.apply(conditions, params)
|
|
3703
|
+
conditions.append(_EXCLUDE_SHARED_PATHS)
|
|
3704
|
+
conditions.append(_EXCLUDE_SYSTEM_ROWS)
|
|
3705
|
+
where = " AND ".join(conditions)
|
|
3706
|
+
params.extend([int(min_entries), int(limit)])
|
|
3707
|
+
with self._pool.connection() as conn:
|
|
3708
|
+
with conn.cursor() as cur:
|
|
3709
|
+
cur.execute(f"SET LOCAL work_mem = '{_AGGREGATE_WORK_MEM}'")
|
|
3710
|
+
cur.execute(
|
|
3711
|
+
f"""
|
|
3712
|
+
SELECT entity_path
|
|
3713
|
+
FROM amfs_memory_entries
|
|
3714
|
+
WHERE {where}
|
|
3715
|
+
GROUP BY entity_path
|
|
3716
|
+
HAVING COUNT(*) >= %s
|
|
3717
|
+
ORDER BY COUNT(*) DESC, entity_path
|
|
3718
|
+
LIMIT %s
|
|
3719
|
+
""",
|
|
3720
|
+
params,
|
|
3721
|
+
)
|
|
3722
|
+
return [r["entity_path"] for r in cur.fetchall()]
|
|
3723
|
+
|
|
3507
3724
|
def agent_summaries(
|
|
3508
3725
|
self,
|
|
3509
3726
|
*,
|
|
@@ -3634,6 +3851,7 @@ class PostgresAdapter(AdapterABC):
|
|
|
3634
3851
|
*,
|
|
3635
3852
|
agent_ids: list[str] | None = None,
|
|
3636
3853
|
scope: SqlScope | None = None,
|
|
3854
|
+
breakdown: bool = True,
|
|
3637
3855
|
) -> dict[str, Any]:
|
|
3638
3856
|
"""Extended aggregate stats in SQL (recalls, weekly deltas, types).
|
|
3639
3857
|
|
|
@@ -3641,6 +3859,16 @@ class PostgresAdapter(AdapterABC):
|
|
|
3641
3859
|
to the entry aggregates only: the reuse-event query below is keyed by
|
|
3642
3860
|
account and reader agent, and an entry-visibility predicate has no
|
|
3643
3861
|
meaning over event rows.
|
|
3862
|
+
|
|
3863
|
+
``breakdown=False`` leaves out the per-agent and per-entity count maps
|
|
3864
|
+
(``agents``, ``entities``) and takes the two distinct counts from one
|
|
3865
|
+
grouped subquery each instead. On a 6,000-agent, 4,000-entity account
|
|
3866
|
+
the maps were 600 KB of a response whose reader wanted two integers.
|
|
3867
|
+
|
|
3868
|
+
``entity_confidence`` buckets the entities by their mean confidence
|
|
3869
|
+
(``healthy`` >= 0.9, ``moderate`` >= 0.7, ``low`` >= 0.4, ``critical``
|
|
3870
|
+
below) with the mean of those means as ``avg`` — the health page's
|
|
3871
|
+
donut, which used to need every entity summary to draw four counts.
|
|
3644
3872
|
"""
|
|
3645
3873
|
conditions = ["namespace = %s", "superseded_at IS NULL"]
|
|
3646
3874
|
params: list[Any] = [self._namespace]
|
|
@@ -3697,23 +3925,72 @@ class PostgresAdapter(AdapterABC):
|
|
|
3697
3925
|
)
|
|
3698
3926
|
row = cur.fetchone()
|
|
3699
3927
|
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3928
|
+
agent_rows: list[Any] = []
|
|
3929
|
+
entity_rows: list[Any] = []
|
|
3930
|
+
# Entities bucketed by mean confidence; one pass over the
|
|
3931
|
+
# per-entity groups either way.
|
|
3932
|
+
bucket_sql = """
|
|
3933
|
+
COUNT(*) AS total_entities,
|
|
3934
|
+
COALESCE(AVG(c), 0) AS entity_confidence_avg,
|
|
3935
|
+
COUNT(*) FILTER (WHERE c >= 0.9) AS entities_healthy,
|
|
3936
|
+
COUNT(*) FILTER (WHERE c >= 0.7 AND c < 0.9) AS entities_moderate,
|
|
3937
|
+
COUNT(*) FILTER (WHERE c >= 0.4 AND c < 0.7) AS entities_low,
|
|
3938
|
+
COUNT(*) FILTER (WHERE c < 0.4) AS entities_critical
|
|
3939
|
+
"""
|
|
3940
|
+
if breakdown:
|
|
3941
|
+
cur.execute(
|
|
3942
|
+
f"""
|
|
3943
|
+
SELECT agent_id, COUNT(*) AS cnt
|
|
3944
|
+
FROM amfs_memory_entries WHERE {where} GROUP BY agent_id
|
|
3945
|
+
""",
|
|
3946
|
+
params,
|
|
3947
|
+
)
|
|
3948
|
+
agent_rows = cur.fetchall()
|
|
3708
3949
|
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3950
|
+
cur.execute(
|
|
3951
|
+
f"""
|
|
3952
|
+
SELECT entity_path, COUNT(*) AS cnt,
|
|
3953
|
+
COALESCE(AVG(confidence), 0) AS c
|
|
3954
|
+
FROM amfs_memory_entries WHERE {where} GROUP BY entity_path
|
|
3955
|
+
""",
|
|
3956
|
+
params,
|
|
3957
|
+
)
|
|
3958
|
+
entity_rows = cur.fetchall()
|
|
3959
|
+
total_agents = len(agent_rows)
|
|
3960
|
+
total_entities = len(entity_rows)
|
|
3961
|
+
# The same edges the pure-Python aggregate uses.
|
|
3962
|
+
buckets = entity_confidence_buckets(r["c"] for r in entity_rows)
|
|
3963
|
+
else:
|
|
3964
|
+
# Grouped subqueries rather than COUNT(DISTINCT ...): the
|
|
3965
|
+
# distinct aggregate forces a sort of every row and keeps
|
|
3966
|
+
# the statement off the parallel plan (see above).
|
|
3967
|
+
cur.execute(
|
|
3968
|
+
f"""
|
|
3969
|
+
SELECT
|
|
3970
|
+
(SELECT COUNT(*) FROM (
|
|
3971
|
+
SELECT 1 FROM amfs_memory_entries WHERE {where}
|
|
3972
|
+
GROUP BY agent_id) a) AS total_agents,
|
|
3973
|
+
e.*
|
|
3974
|
+
FROM (
|
|
3975
|
+
SELECT {bucket_sql}
|
|
3976
|
+
FROM (
|
|
3977
|
+
SELECT COALESCE(AVG(confidence), 0) AS c
|
|
3978
|
+
FROM amfs_memory_entries WHERE {where}
|
|
3979
|
+
GROUP BY entity_path) g
|
|
3980
|
+
) e
|
|
3981
|
+
""",
|
|
3982
|
+
[*params, *params],
|
|
3983
|
+
)
|
|
3984
|
+
counts = cur.fetchone() or {}
|
|
3985
|
+
total_agents = int(counts.get("total_agents") or 0)
|
|
3986
|
+
total_entities = int(counts.get("total_entities") or 0)
|
|
3987
|
+
buckets = {
|
|
3988
|
+
"avg": float(counts.get("entity_confidence_avg") or 0),
|
|
3989
|
+
"healthy": int(counts.get("entities_healthy") or 0),
|
|
3990
|
+
"moderate": int(counts.get("entities_moderate") or 0),
|
|
3991
|
+
"low": int(counts.get("entities_low") or 0),
|
|
3992
|
+
"critical": int(counts.get("entities_critical") or 0),
|
|
3993
|
+
}
|
|
3717
3994
|
|
|
3718
3995
|
cur.execute(
|
|
3719
3996
|
f"""
|
|
@@ -3781,14 +4058,17 @@ class PostgresAdapter(AdapterABC):
|
|
|
3781
4058
|
# and the dashboard hides the trend line.
|
|
3782
4059
|
pass
|
|
3783
4060
|
|
|
3784
|
-
|
|
4061
|
+
out: dict[str, Any] = {
|
|
3785
4062
|
"recalls_this_week": recalls_this_week,
|
|
3786
4063
|
"recalls_last_week": recalls_last_week,
|
|
3787
4064
|
"total_entries": row["total_entries"] if row else 0,
|
|
3788
|
-
"total_entities":
|
|
3789
|
-
"total_agents":
|
|
3790
|
-
|
|
3791
|
-
|
|
4065
|
+
"total_entities": total_entities,
|
|
4066
|
+
"total_agents": total_agents,
|
|
4067
|
+
}
|
|
4068
|
+
if breakdown:
|
|
4069
|
+
out["agents"] = {r["agent_id"]: r["cnt"] for r in agent_rows}
|
|
4070
|
+
out["entities"] = {r["entity_path"]: r["cnt"] for r in entity_rows}
|
|
4071
|
+
out.update({
|
|
3792
4072
|
"confidence_avg": float(row["confidence_avg"] or 0) if row else 0.0,
|
|
3793
4073
|
"confidence_min": float(row["confidence_min"] or 0) if row else 0.0,
|
|
3794
4074
|
"confidence_max": float(row["confidence_max"] or 0) if row else 0.0,
|
|
@@ -3802,7 +4082,9 @@ class PostgresAdapter(AdapterABC):
|
|
|
3802
4082
|
"memory_type_counts": {
|
|
3803
4083
|
(r["memory_type"] or "fact"): r["cnt"] for r in type_rows
|
|
3804
4084
|
},
|
|
3805
|
-
|
|
4085
|
+
"entity_confidence": buckets,
|
|
4086
|
+
})
|
|
4087
|
+
return out
|
|
3806
4088
|
|
|
3807
4089
|
def share_stats(
|
|
3808
4090
|
self,
|
|
@@ -5194,11 +5476,18 @@ class PostgresAdapter(AdapterABC):
|
|
|
5194
5476
|
namespace: str = "default",
|
|
5195
5477
|
branch: str = "main",
|
|
5196
5478
|
*,
|
|
5479
|
+
scope: str | None = None,
|
|
5480
|
+
written_by: str | None = None,
|
|
5197
5481
|
relevant_to: list[str] | None = None,
|
|
5198
5482
|
limit: int | None = None,
|
|
5199
5483
|
) -> list[Digest]:
|
|
5200
|
-
"""List digests, optionally filtered by type and
|
|
5484
|
+
"""List digests, optionally filtered by type, branch and scope.
|
|
5201
5485
|
|
|
5486
|
+
``scope`` keeps the digests compiled for exactly that scope — an
|
|
5487
|
+
agent's brief, one entity — in SQL, so a page asking about one agent
|
|
5488
|
+
does not receive every brief on the account (5 MB on a large one) to
|
|
5489
|
+
pick its own out of. ``written_by`` keeps the digests whose scope is
|
|
5490
|
+
an entity path that agent has live entries under.
|
|
5202
5491
|
``relevant_to`` keeps only digests that name one of the given strings
|
|
5203
5492
|
— as their scope, or anywhere in their summary. The briefing scores a
|
|
5204
5493
|
digest only when the entity or agent it is asked about appears in one
|
|
@@ -5213,6 +5502,21 @@ class PostgresAdapter(AdapterABC):
|
|
|
5213
5502
|
if digest_type:
|
|
5214
5503
|
where.append("digest_type = %s")
|
|
5215
5504
|
params.append(digest_type.value)
|
|
5505
|
+
if scope:
|
|
5506
|
+
where.append("scope = %s")
|
|
5507
|
+
params.append(scope)
|
|
5508
|
+
if written_by:
|
|
5509
|
+
# The agent's entity paths, resolved in the database; RLS on the
|
|
5510
|
+
# entries table keeps the subquery to what the caller may see.
|
|
5511
|
+
# Same branch as the digests, and without benchmark or system
|
|
5512
|
+
# rows — the set ``entity_summaries`` would list for the agent,
|
|
5513
|
+
# so a digest shows up here only for a topic that page shows.
|
|
5514
|
+
where.append(
|
|
5515
|
+
"scope IN (SELECT DISTINCT entity_path FROM amfs_memory_entries"
|
|
5516
|
+
" WHERE namespace = %s AND branch = %s AND agent_id = %s"
|
|
5517
|
+
f" AND superseded_at IS NULL AND {_EXCLUDE_SYSTEM_ROWS})"
|
|
5518
|
+
)
|
|
5519
|
+
params.extend([namespace, branch, written_by])
|
|
5216
5520
|
if account_id:
|
|
5217
5521
|
where.append("account_id = %s")
|
|
5218
5522
|
params.append(account_id)
|
|
@@ -5761,7 +6065,37 @@ class PostgresAdapter(AdapterABC):
|
|
|
5761
6065
|
params,
|
|
5762
6066
|
)
|
|
5763
6067
|
|
|
5764
|
-
|
|
6068
|
+
#: ``sort`` values ``list_agents_enriched`` accepts, and the ORDER BY each is.
|
|
6069
|
+
_ENRICHED_AGENT_SORTS: dict[str, str] = {
|
|
6070
|
+
"last_active": "last_active DESC NULLS LAST, agent_id",
|
|
6071
|
+
"entries_written": "entries_written DESC, agent_id",
|
|
6072
|
+
"created_at": "created_at DESC NULLS LAST, agent_id",
|
|
6073
|
+
"agent_id": "agent_id",
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
def list_agents_enriched(
|
|
6077
|
+
self,
|
|
6078
|
+
namespace: str = "default",
|
|
6079
|
+
*,
|
|
6080
|
+
agent_ids: list[str] | None = None,
|
|
6081
|
+
q: str | None = None,
|
|
6082
|
+
platform: str | None = None,
|
|
6083
|
+
sort: str = "last_active",
|
|
6084
|
+
limit: int | None = None,
|
|
6085
|
+
offset: int = 0,
|
|
6086
|
+
) -> list[dict[str, Any]]:
|
|
6087
|
+
"""Enriched agent rows, optionally one page of them.
|
|
6088
|
+
|
|
6089
|
+
*agent_ids* narrows to the agents the caller may see, so a page is a
|
|
6090
|
+
page of visible agents. *q* is a case-insensitive substring match on
|
|
6091
|
+
the agent id, description, model, platform and primary repo;
|
|
6092
|
+
*platform* keeps one platform. With *limit* set, every row carries
|
|
6093
|
+
``total`` — the count before paging — and ``active_7d``, how many of
|
|
6094
|
+
those were active in the last seven days, so a directory can show
|
|
6095
|
+
"showing 50 of 6,356 · 412 active this week" without a second query.
|
|
6096
|
+
Unpaged, the whole roster is returned as before (3 MB on that account;
|
|
6097
|
+
the dashboard pages).
|
|
6098
|
+
"""
|
|
5765
6099
|
account_id = self._get_current_account_id()
|
|
5766
6100
|
if account_id:
|
|
5767
6101
|
account_filter = "AND a.account_id = %s"
|
|
@@ -5773,6 +6107,7 @@ class PostgresAdapter(AdapterABC):
|
|
|
5773
6107
|
entry_account_filter = ""
|
|
5774
6108
|
graph_account_filter = ""
|
|
5775
6109
|
acct_params = []
|
|
6110
|
+
agent_ids_filter = "AND a.agent_id = ANY(%s)" if agent_ids is not None else ""
|
|
5776
6111
|
|
|
5777
6112
|
sql = f"""
|
|
5778
6113
|
WITH agent_entries AS (
|
|
@@ -5845,14 +6180,47 @@ class PostgresAdapter(AdapterABC):
|
|
|
5845
6180
|
LEFT JOIN agent_collab c ON c.agent_id = a.agent_id
|
|
5846
6181
|
LEFT JOIN amfs_agent_group_members gm
|
|
5847
6182
|
ON gm.agent_id = a.agent_id AND gm.namespace = a.namespace
|
|
5848
|
-
WHERE a.namespace = %s {account_filter}
|
|
5849
|
-
ORDER BY last_active DESC NULLS LAST
|
|
6183
|
+
WHERE a.namespace = %s {account_filter} {agent_ids_filter}
|
|
5850
6184
|
"""
|
|
5851
6185
|
params: list[Any] = [
|
|
5852
6186
|
namespace, *acct_params,
|
|
5853
6187
|
namespace, *acct_params,
|
|
5854
6188
|
namespace, *acct_params,
|
|
5855
6189
|
]
|
|
6190
|
+
if agent_ids is not None:
|
|
6191
|
+
params.append(list(agent_ids))
|
|
6192
|
+
if sort not in self._ENRICHED_AGENT_SORTS:
|
|
6193
|
+
raise ValueError(f"unknown sort {sort!r}")
|
|
6194
|
+
order_by = self._ENRICHED_AGENT_SORTS[sort]
|
|
6195
|
+
# The roster as a subquery so the search sees the computed columns
|
|
6196
|
+
# and the page count is taken over the rows the search kept.
|
|
6197
|
+
outer: list[str] = []
|
|
6198
|
+
if q:
|
|
6199
|
+
pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
6200
|
+
outer.append(
|
|
6201
|
+
"(x.agent_id ILIKE %s OR x.description ILIKE %s OR x.model ILIKE %s"
|
|
6202
|
+
" OR x.platform ILIKE %s OR x.primary_repo ILIKE %s)"
|
|
6203
|
+
)
|
|
6204
|
+
params.extend([pattern] * 5)
|
|
6205
|
+
if platform:
|
|
6206
|
+
outer.append("x.platform = %s")
|
|
6207
|
+
params.append(platform)
|
|
6208
|
+
outer_where = ("WHERE " + " AND ".join(outer)) if outer else ""
|
|
6209
|
+
page = ""
|
|
6210
|
+
if limit is not None:
|
|
6211
|
+
page = "LIMIT %s OFFSET %s"
|
|
6212
|
+
params.extend([int(limit), max(0, int(offset))])
|
|
6213
|
+
sql = f"""
|
|
6214
|
+
SELECT x.*,
|
|
6215
|
+
COUNT(*) OVER () AS total,
|
|
6216
|
+
COUNT(*) FILTER (
|
|
6217
|
+
WHERE x.last_active >= NOW() - INTERVAL '7 days'
|
|
6218
|
+
) OVER () AS active_7d
|
|
6219
|
+
FROM ({sql}) x
|
|
6220
|
+
{outer_where}
|
|
6221
|
+
ORDER BY {order_by}
|
|
6222
|
+
{page}
|
|
6223
|
+
"""
|
|
5856
6224
|
|
|
5857
6225
|
with self._pool.connection() as conn:
|
|
5858
6226
|
with conn.cursor() as cur:
|
|
@@ -5865,6 +6233,11 @@ class PostgresAdapter(AdapterABC):
|
|
|
5865
6233
|
if isinstance(tags, str):
|
|
5866
6234
|
tags = json.loads(tags)
|
|
5867
6235
|
results.append({
|
|
6236
|
+
**(
|
|
6237
|
+
{"total": int(r["total"]), "active_7d": int(r["active_7d"] or 0)}
|
|
6238
|
+
if limit is not None
|
|
6239
|
+
else {}
|
|
6240
|
+
),
|
|
5868
6241
|
"agent_id": r["agent_id"],
|
|
5869
6242
|
"created_at": r["created_at"],
|
|
5870
6243
|
"entries_written": r["entries_written"],
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/async_adapter.py
RENAMED
|
File without changes
|
{amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/evidence_sql.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/retention.py
RENAMED
|
File without changes
|
|
File without changes
|
{amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/tenant_context.py
RENAMED
|
File without changes
|
{amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/tenant_gucs.py
RENAMED
|
File without changes
|
{amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/trace_partitions.py
RENAMED
|
File without changes
|