luckiest-co 1.0.46 → 1.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +8 -0
- package/skills/video-studio/SKILL.md +6 -5
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio/scripts/qa.mjs +64 -2
- package/skills/video-studio/scripts/qa.test.mjs +29 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -12,16 +12,19 @@ Database location: ~/.local/share/last30days/research.db
|
|
|
12
12
|
|
|
13
13
|
import argparse
|
|
14
14
|
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
15
17
|
import sqlite3
|
|
16
18
|
import sys
|
|
19
|
+
from contextlib import contextmanager
|
|
17
20
|
from datetime import datetime, timedelta, timezone
|
|
18
21
|
from pathlib import Path
|
|
19
|
-
from typing import Any, Dict, List, Optional
|
|
22
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
20
23
|
|
|
21
24
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
22
25
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
23
26
|
|
|
24
|
-
from lib import schema
|
|
27
|
+
from lib import dedupe, entity_extract, schema
|
|
25
28
|
|
|
26
29
|
DB_DIR = Path.home() / ".local" / "share" / "last30days"
|
|
27
30
|
DB_PATH = DB_DIR / "research.db"
|
|
@@ -34,6 +37,45 @@ def _get_db_path() -> Path:
|
|
|
34
37
|
return _db_override or DB_PATH
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
@contextmanager
|
|
41
|
+
def scoped_db(db_path: Optional[Path]) -> Iterator[None]:
|
|
42
|
+
"""Route all store access inside the block to ``db_path``.
|
|
43
|
+
|
|
44
|
+
``None`` keeps the shared store. Scoped runs (``--save-dir``) use this so
|
|
45
|
+
their findings land next to their briefs instead of leaking into the
|
|
46
|
+
shared research.db that unscoped searches read.
|
|
47
|
+
"""
|
|
48
|
+
global _db_override
|
|
49
|
+
if db_path is None:
|
|
50
|
+
yield
|
|
51
|
+
return
|
|
52
|
+
previous = _db_override
|
|
53
|
+
_db_override = Path(db_path)
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
_db_override = previous
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_private_db_files(db_path: Optional[Path] = None) -> Path:
|
|
61
|
+
"""Create/harden the research database and SQLite sidecars owner-only."""
|
|
62
|
+
path = db_path or _get_db_path()
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
if not path.exists():
|
|
65
|
+
try:
|
|
66
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
67
|
+
except FileExistsError:
|
|
68
|
+
pass
|
|
69
|
+
else:
|
|
70
|
+
os.close(fd)
|
|
71
|
+
for candidate in (path, Path(f"{path}-wal"), Path(f"{path}-shm")):
|
|
72
|
+
try:
|
|
73
|
+
candidate.chmod(0o600)
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
pass
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
|
|
37
79
|
SCHEMA_V1 = """
|
|
38
80
|
PRAGMA journal_mode=WAL;
|
|
39
81
|
PRAGMA synchronous=NORMAL;
|
|
@@ -123,20 +165,20 @@ CREATE TABLE IF NOT EXISTS settings (
|
|
|
123
165
|
);
|
|
124
166
|
"""
|
|
125
167
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"""
|
|
168
|
+
_DEFAULT_SETTINGS = {
|
|
169
|
+
"daily_budget": "5.00",
|
|
170
|
+
"delivery_channel": "",
|
|
171
|
+
"delivery_mode": "announce",
|
|
172
|
+
"briefing_format": "concise",
|
|
173
|
+
"default_schedule": "0 8 * * *",
|
|
174
|
+
}
|
|
134
175
|
|
|
135
176
|
_UPDATABLE_RUN_COLUMNS = frozenset({
|
|
136
177
|
"source_mode",
|
|
137
178
|
"prompt_tokens",
|
|
138
179
|
"completion_tokens",
|
|
139
180
|
"token_cost",
|
|
181
|
+
"cost_unknown",
|
|
140
182
|
"duration_seconds",
|
|
141
183
|
"status",
|
|
142
184
|
"error_message",
|
|
@@ -181,6 +223,28 @@ CREATE INDEX IF NOT EXISTS idx_finding_sightings_topic_seen
|
|
|
181
223
|
ON finding_sightings(topic_id, seen_at);
|
|
182
224
|
CREATE INDEX IF NOT EXISTS idx_finding_sightings_url
|
|
183
225
|
ON finding_sightings(source_url);
|
|
226
|
+
""",
|
|
227
|
+
3: """
|
|
228
|
+
CREATE TABLE IF NOT EXISTS discovery_topics (
|
|
229
|
+
id INTEGER PRIMARY KEY,
|
|
230
|
+
name TEXT NOT NULL,
|
|
231
|
+
normalized_name TEXT NOT NULL UNIQUE,
|
|
232
|
+
entity_key TEXT,
|
|
233
|
+
domain TEXT,
|
|
234
|
+
first_surfaced TEXT NOT NULL,
|
|
235
|
+
last_surfaced TEXT NOT NULL,
|
|
236
|
+
surface_count INTEGER NOT NULL DEFAULT 1,
|
|
237
|
+
status TEXT NOT NULL DEFAULT 'surfaced' CHECK(status IN ('surfaced','covered')),
|
|
238
|
+
covered_at TEXT,
|
|
239
|
+
last_run_ref TEXT
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
CREATE INDEX IF NOT EXISTS idx_discovery_topics_status_surfaced
|
|
243
|
+
ON discovery_topics(status, last_surfaced);
|
|
244
|
+
""",
|
|
245
|
+
4: """
|
|
246
|
+
ALTER TABLE research_runs ADD COLUMN cost_unknown INTEGER NOT NULL DEFAULT 1;
|
|
247
|
+
UPDATE research_runs SET cost_unknown = 0 WHERE token_cost > 0;
|
|
184
248
|
""",
|
|
185
249
|
}
|
|
186
250
|
|
|
@@ -208,9 +272,22 @@ def init_db(db_path: Optional[Path] = None) -> Path:
|
|
|
208
272
|
conn = _connect(path)
|
|
209
273
|
try:
|
|
210
274
|
conn.executescript(SCHEMA_V1)
|
|
211
|
-
conn.
|
|
275
|
+
if not conn.execute("SELECT 1 FROM schema_version WHERE version = 1").fetchone():
|
|
276
|
+
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (1)")
|
|
277
|
+
existing_settings = {row["key"] for row in conn.execute("SELECT key FROM settings")}
|
|
278
|
+
missing_defaults = [
|
|
279
|
+
(key, value) for key, value in _DEFAULT_SETTINGS.items()
|
|
280
|
+
if key not in existing_settings
|
|
281
|
+
]
|
|
282
|
+
if missing_defaults:
|
|
283
|
+
conn.executemany(
|
|
284
|
+
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
|
285
|
+
missing_defaults,
|
|
286
|
+
)
|
|
287
|
+
conn.commit()
|
|
212
288
|
_run_migrations(conn)
|
|
213
289
|
conn.commit()
|
|
290
|
+
_backfill_owner_sightings(conn)
|
|
214
291
|
finally:
|
|
215
292
|
conn.close()
|
|
216
293
|
|
|
@@ -219,27 +296,88 @@ def init_db(db_path: Optional[Path] = None) -> Path:
|
|
|
219
296
|
|
|
220
297
|
def _run_migrations(conn: sqlite3.Connection):
|
|
221
298
|
"""Apply pending schema migrations."""
|
|
222
|
-
current = conn.execute(
|
|
223
|
-
|
|
224
|
-
|
|
299
|
+
current = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] or 0
|
|
300
|
+
if current >= max(MIGRATIONS, default=0):
|
|
301
|
+
return
|
|
302
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
303
|
+
try:
|
|
304
|
+
current = conn.execute(
|
|
305
|
+
"SELECT MAX(version) FROM schema_version"
|
|
306
|
+
).fetchone()[0] or 0
|
|
307
|
+
for version in sorted(MIGRATIONS):
|
|
308
|
+
if version <= current:
|
|
309
|
+
continue
|
|
310
|
+
# executescript commits before running; keep DDL and its marker atomic.
|
|
311
|
+
statement = ""
|
|
312
|
+
for character in MIGRATIONS[version]:
|
|
313
|
+
statement += character
|
|
314
|
+
if character == ";" and sqlite3.complete_statement(statement):
|
|
315
|
+
conn.execute(statement)
|
|
316
|
+
statement = ""
|
|
317
|
+
if statement.strip():
|
|
318
|
+
conn.execute(statement)
|
|
319
|
+
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
|
|
320
|
+
conn.commit()
|
|
321
|
+
except BaseException:
|
|
322
|
+
conn.rollback()
|
|
323
|
+
raise
|
|
225
324
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
325
|
+
|
|
326
|
+
def _backfill_owner_sightings(conn: sqlite3.Connection) -> None:
|
|
327
|
+
"""Preserve legacy first observations before aggregate ownership can change."""
|
|
328
|
+
marker = "_topic_sightings_backfilled_v1"
|
|
329
|
+
if conn.execute("SELECT 1 FROM settings WHERE key = ?", (marker,)).fetchone():
|
|
330
|
+
return
|
|
331
|
+
with conn:
|
|
332
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
333
|
+
if conn.execute("SELECT 1 FROM settings WHERE key = ?", (marker,)).fetchone():
|
|
334
|
+
return
|
|
335
|
+
conn.execute(
|
|
336
|
+
"""INSERT INTO finding_sightings
|
|
337
|
+
(finding_id, run_id, topic_id, source, source_url, source_title,
|
|
338
|
+
engagement_score, relevance_score, seen_at)
|
|
339
|
+
SELECT f.id, NULL, f.topic_id, f.source, COALESCE(f.source_url, ''),
|
|
340
|
+
f.source_title, f.engagement_score, f.relevance_score, f.first_seen
|
|
341
|
+
FROM findings f
|
|
342
|
+
WHERE f.topic_id IS NOT NULL AND f.first_seen IS NOT NULL
|
|
343
|
+
AND NOT EXISTS (
|
|
344
|
+
SELECT 1 FROM finding_sightings s
|
|
345
|
+
WHERE s.finding_id = f.id AND s.topic_id = f.topic_id
|
|
346
|
+
AND s.seen_at <= f.first_seen
|
|
347
|
+
)"""
|
|
348
|
+
)
|
|
349
|
+
conn.execute("INSERT INTO settings (key, value) VALUES (?, '1')", (marker,))
|
|
232
350
|
|
|
233
351
|
|
|
234
352
|
# --- Topics ---
|
|
235
353
|
|
|
236
354
|
|
|
355
|
+
# Ledger dates survive aggregate ownership reassignment; references are a legacy fallback.
|
|
356
|
+
_FINDING_TOPICS_SQL = """
|
|
357
|
+
SELECT finding_id, topic_id,
|
|
358
|
+
COALESCE(MIN(sighting_seen), MIN(legacy_first_seen)) AS first_seen
|
|
359
|
+
FROM (
|
|
360
|
+
SELECT id AS finding_id, topic_id, NULL AS sighting_seen, first_seen AS legacy_first_seen
|
|
361
|
+
FROM findings WHERE topic_id IS NOT NULL
|
|
362
|
+
UNION ALL
|
|
363
|
+
SELECT finding_id, topic_id, seen_at, NULL
|
|
364
|
+
FROM finding_sightings WHERE topic_id IS NOT NULL
|
|
365
|
+
UNION ALL
|
|
366
|
+
SELECT f.id, r.topic_id, NULL, r.run_date FROM findings f
|
|
367
|
+
JOIN research_runs r ON r.id = f.run_id WHERE r.topic_id IS NOT NULL
|
|
368
|
+
)
|
|
369
|
+
GROUP BY finding_id, topic_id
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
|
|
237
373
|
def add_topic(
|
|
238
374
|
name: str,
|
|
239
375
|
search_queries: Optional[List[str]] = None,
|
|
240
376
|
schedule: str = "0 8 * * *",
|
|
377
|
+
*,
|
|
378
|
+
update_existing: bool = True,
|
|
241
379
|
) -> Dict[str, Any]:
|
|
242
|
-
"""Add a topic
|
|
380
|
+
"""Add a topic, optionally preserving an existing topic's configuration."""
|
|
243
381
|
init_db()
|
|
244
382
|
conn = _connect()
|
|
245
383
|
try:
|
|
@@ -250,8 +388,9 @@ def add_topic(
|
|
|
250
388
|
ON CONFLICT(name) DO UPDATE SET
|
|
251
389
|
search_queries = excluded.search_queries,
|
|
252
390
|
schedule = excluded.schedule,
|
|
253
|
-
updated_at = datetime('now')
|
|
254
|
-
|
|
391
|
+
updated_at = datetime('now')
|
|
392
|
+
WHERE ?""",
|
|
393
|
+
(name, queries_json, schedule, update_existing),
|
|
255
394
|
)
|
|
256
395
|
conn.commit()
|
|
257
396
|
row = conn.execute(
|
|
@@ -267,14 +406,44 @@ def remove_topic(name: str) -> bool:
|
|
|
267
406
|
init_db()
|
|
268
407
|
conn = _connect()
|
|
269
408
|
try:
|
|
409
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
270
410
|
row = conn.execute(
|
|
271
411
|
"SELECT id FROM topics WHERE name = ?", (name,)
|
|
272
412
|
).fetchone()
|
|
273
413
|
if not row:
|
|
274
414
|
return False
|
|
275
415
|
topic_id = row["id"]
|
|
276
|
-
|
|
277
|
-
|
|
416
|
+
findings = conn.execute(
|
|
417
|
+
f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
|
|
418
|
+
SELECT f.id, f.topic_id, f.run_id, r.topic_id AS run_topic_id,
|
|
419
|
+
(SELECT MIN(m.topic_id) FROM memberships m
|
|
420
|
+
WHERE m.finding_id = f.id AND m.topic_id != ?) AS survivor
|
|
421
|
+
FROM findings f
|
|
422
|
+
LEFT JOIN research_runs r ON r.id = f.run_id
|
|
423
|
+
WHERE f.id IN (SELECT finding_id FROM memberships WHERE topic_id = ?)""",
|
|
424
|
+
(topic_id, topic_id),
|
|
425
|
+
).fetchall()
|
|
426
|
+
for finding in findings:
|
|
427
|
+
if finding["survivor"] is None:
|
|
428
|
+
conn.execute("DELETE FROM findings WHERE id = ?", (finding["id"],))
|
|
429
|
+
continue
|
|
430
|
+
owner_id = finding["topic_id"]
|
|
431
|
+
if owner_id == topic_id:
|
|
432
|
+
owner_id = finding["survivor"]
|
|
433
|
+
run_id = finding["run_id"]
|
|
434
|
+
if finding["run_topic_id"] == topic_id:
|
|
435
|
+
surviving_run = conn.execute(
|
|
436
|
+
"""SELECT s.run_id FROM finding_sightings s
|
|
437
|
+
JOIN research_runs r ON r.id = s.run_id
|
|
438
|
+
WHERE s.finding_id = ? AND s.topic_id != ? AND r.topic_id != ?
|
|
439
|
+
ORDER BY s.seen_at DESC, s.id DESC LIMIT 1""",
|
|
440
|
+
(finding["id"], topic_id, topic_id),
|
|
441
|
+
).fetchone()
|
|
442
|
+
run_id = surviving_run["run_id"] if surviving_run else None
|
|
443
|
+
conn.execute(
|
|
444
|
+
"UPDATE findings SET topic_id = ?, run_id = ? WHERE id = ?",
|
|
445
|
+
(owner_id, run_id, finding["id"]),
|
|
446
|
+
)
|
|
278
447
|
conn.execute("DELETE FROM research_runs WHERE topic_id = ?", (topic_id,))
|
|
279
448
|
conn.execute("DELETE FROM topics WHERE id = ?", (topic_id,))
|
|
280
449
|
conn.commit()
|
|
@@ -289,8 +458,9 @@ def list_topics() -> List[Dict[str, Any]]:
|
|
|
289
458
|
conn = _connect()
|
|
290
459
|
try:
|
|
291
460
|
rows = conn.execute(
|
|
292
|
-
"""
|
|
293
|
-
|
|
461
|
+
f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
|
|
462
|
+
SELECT t.*,
|
|
463
|
+
(SELECT COUNT(*) FROM memberships WHERE topic_id = t.id) as finding_count,
|
|
294
464
|
(SELECT MAX(run_date) FROM research_runs WHERE topic_id = t.id) as last_run,
|
|
295
465
|
(SELECT status FROM research_runs WHERE topic_id = t.id
|
|
296
466
|
ORDER BY created_at DESC LIMIT 1) as last_status
|
|
@@ -326,7 +496,7 @@ def record_run(
|
|
|
326
496
|
duration_seconds: float = 0,
|
|
327
497
|
prompt_tokens: int = 0,
|
|
328
498
|
completion_tokens: int = 0,
|
|
329
|
-
token_cost: float =
|
|
499
|
+
token_cost: Optional[float] = None,
|
|
330
500
|
) -> int:
|
|
331
501
|
"""Record a research run. Returns the run ID."""
|
|
332
502
|
conn = _connect()
|
|
@@ -334,11 +504,12 @@ def record_run(
|
|
|
334
504
|
cursor = conn.execute(
|
|
335
505
|
"""INSERT INTO research_runs
|
|
336
506
|
(topic_id, run_date, source_mode, status, error_message,
|
|
337
|
-
duration_seconds, prompt_tokens, completion_tokens, token_cost)
|
|
338
|
-
VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?)""",
|
|
507
|
+
duration_seconds, prompt_tokens, completion_tokens, token_cost, cost_unknown)
|
|
508
|
+
VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
339
509
|
(
|
|
340
510
|
topic_id, source_mode, status, error_message,
|
|
341
511
|
duration_seconds, prompt_tokens, completion_tokens, token_cost,
|
|
512
|
+
int(token_cost is None),
|
|
342
513
|
),
|
|
343
514
|
)
|
|
344
515
|
conn.commit()
|
|
@@ -424,7 +595,7 @@ def store_findings(
|
|
|
424
595
|
|
|
425
596
|
for url, f in with_urls:
|
|
426
597
|
existing = existing_by_url.get(url)
|
|
427
|
-
new_engagement = f.get("engagement_score"
|
|
598
|
+
new_engagement = f.get("engagement_score") or 0
|
|
428
599
|
if existing:
|
|
429
600
|
update_rows.append((
|
|
430
601
|
max(new_engagement, existing["engagement_score"] or 0),
|
|
@@ -543,8 +714,8 @@ def _record_sightings(
|
|
|
543
714
|
finding.get("source", "unknown"),
|
|
544
715
|
url,
|
|
545
716
|
finding.get("source_title") or finding.get("title", ""),
|
|
546
|
-
finding.get("engagement_score"
|
|
547
|
-
finding.get("relevance_score"
|
|
717
|
+
finding.get("engagement_score") if finding.get("engagement_score") is not None else 0,
|
|
718
|
+
finding.get("relevance_score") if finding.get("relevance_score") is not None else 0,
|
|
548
719
|
))
|
|
549
720
|
|
|
550
721
|
if not sighting_rows:
|
|
@@ -666,25 +837,31 @@ def _delta_source_counts(
|
|
|
666
837
|
def get_new_findings(
|
|
667
838
|
topic_id: int,
|
|
668
839
|
since: Optional[str] = None,
|
|
840
|
+
before: Optional[str] = None,
|
|
669
841
|
) -> List[Dict[str, Any]]:
|
|
670
|
-
"""Get findings for
|
|
842
|
+
"""Get findings by their first observation for this topic within the date bounds."""
|
|
671
843
|
conn = _connect()
|
|
672
844
|
try:
|
|
845
|
+
date_filter = ""
|
|
846
|
+
parameters: List[Any] = [topic_id]
|
|
673
847
|
if since:
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
848
|
+
date_filter += " AND m.first_seen >= ?"
|
|
849
|
+
parameters.append(since)
|
|
850
|
+
if before:
|
|
851
|
+
date_filter += " AND m.first_seen < ?"
|
|
852
|
+
parameters.append(before)
|
|
853
|
+
rows = conn.execute(
|
|
854
|
+
f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
|
|
855
|
+
SELECT f.*, m.first_seen AS topic_first_seen FROM findings f
|
|
856
|
+
JOIN memberships m ON m.finding_id = f.id
|
|
857
|
+
WHERE m.topic_id = ? AND f.dismissed = 0{date_filter}
|
|
858
|
+
ORDER BY m.first_seen DESC""",
|
|
859
|
+
parameters,
|
|
860
|
+
).fetchall()
|
|
861
|
+
findings = [dict(r) for r in rows]
|
|
862
|
+
for finding in findings:
|
|
863
|
+
finding["first_seen"] = finding.pop("topic_first_seen")
|
|
864
|
+
return findings
|
|
688
865
|
finally:
|
|
689
866
|
conn.close()
|
|
690
867
|
|
|
@@ -740,6 +917,209 @@ def dismiss_finding(finding_id: int):
|
|
|
740
917
|
update_finding(finding_id, dismissed=1)
|
|
741
918
|
|
|
742
919
|
|
|
920
|
+
# --- Discovery topic queue ---
|
|
921
|
+
|
|
922
|
+
# Conservative floor for fuzzy queue matching (overlap coefficient of entity
|
|
923
|
+
# tokens via entity_extract.entity_overlap). Tunable: raise toward 1.0 for
|
|
924
|
+
# stricter matching, lower for looser. Matching is annotate-only - a fuzzy
|
|
925
|
+
# match stamps prior-surfacing context onto an incoming topic but NEVER merges
|
|
926
|
+
# queue rows, so a too-loose threshold can mislabel a card yet never lose data.
|
|
927
|
+
DISCOVERY_QUEUE_OVERLAP_THRESHOLD = 0.6
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _normalize_discovery_name(name: str) -> str:
|
|
931
|
+
"""Queue identity: lowercased, punctuation-stripped, whitespace-collapsed
|
|
932
|
+
(thin alias for dedupe.normalize_text)."""
|
|
933
|
+
return dedupe.normalize_text(name)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _discovery_entity_key(name: str) -> str:
|
|
937
|
+
"""Sorted joined significant tokens, computed once at write time."""
|
|
938
|
+
return " ".join(sorted(entity_extract.extract_text_entities(name)))
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _discovery_anchor_entities(name: str) -> set[str]:
|
|
942
|
+
"""Anchor tokens for fuzzy matching: capitalized, all-caps, or
|
|
943
|
+
digit-bearing words minus stopwords (product/person/version anchors).
|
|
944
|
+
|
|
945
|
+
Generic lowercase words ("chat", "templates") are excluded so two angles
|
|
946
|
+
on the same subject ("Gemma 4 chat templates" / "Gemma 4 tool calling
|
|
947
|
+
fixes") cross-match while different subjects sharing filler words don't.
|
|
948
|
+
"""
|
|
949
|
+
anchors = set()
|
|
950
|
+
for word in re.sub(r"[^\w\s]", " ", name).split():
|
|
951
|
+
lower = word.casefold()
|
|
952
|
+
if lower in entity_extract.ENTITY_STOPWORDS:
|
|
953
|
+
continue
|
|
954
|
+
if entity_extract.has_anchor_signal(word):
|
|
955
|
+
anchors.add(lower)
|
|
956
|
+
return anchors
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def record_discovery_surfacing(
|
|
960
|
+
name: str,
|
|
961
|
+
domain: str = "",
|
|
962
|
+
run_ref: str = "",
|
|
963
|
+
as_of: str = "",
|
|
964
|
+
inherit_covered_at: Optional[str] = None,
|
|
965
|
+
) -> Dict[str, Any]:
|
|
966
|
+
"""Upsert a queue row by normalized name.
|
|
967
|
+
|
|
968
|
+
A fresh topic inserts with surface_count 1; re-surfacing the same
|
|
969
|
+
normalized name increments the count and refreshes last_surfaced and
|
|
970
|
+
last_run_ref (first_surfaced never changes). Returns the resulting row.
|
|
971
|
+
|
|
972
|
+
A resurfacing with a blank domain (e.g. a global-trending sweep with no
|
|
973
|
+
domain) never blanks a domain recorded by an earlier, domain-scoped
|
|
974
|
+
surfacing - the stored domain only changes when the incoming domain is
|
|
975
|
+
non-empty. ``domain`` is normalized to "" here (never NULL bound) so the
|
|
976
|
+
column's storage convention stays consistent regardless of whether a
|
|
977
|
+
caller passes "" or None.
|
|
978
|
+
|
|
979
|
+
``inherit_covered_at`` makes a FRESH row be born covered (status
|
|
980
|
+
'covered', covered_at set to the given date). Callers pass it when this
|
|
981
|
+
name fuzzy-matched an already-covered prior row, so a user's covered
|
|
982
|
+
mark survives judge naming drift instead of forking into a fresh
|
|
983
|
+
uncovered row. An existing row's status/covered_at are never modified
|
|
984
|
+
by this function - the ON CONFLICT path deliberately ignores it.
|
|
985
|
+
|
|
986
|
+
Idempotency guard: when the existing row's last_run_ref already equals
|
|
987
|
+
this call's (non-blank) run_ref, the surfacing was ALREADY counted by
|
|
988
|
+
this run identity - a retry (e.g. a --finalize re-run with a corrected
|
|
989
|
+
angles file) returns the row unchanged instead of double-counting.
|
|
990
|
+
Blank run_refs never guard, so callers without a run identity keep the
|
|
991
|
+
every-call-increments behavior.
|
|
992
|
+
"""
|
|
993
|
+
init_db()
|
|
994
|
+
domain = domain or ""
|
|
995
|
+
normalized = _normalize_discovery_name(name)
|
|
996
|
+
entity_key = _discovery_entity_key(name)
|
|
997
|
+
status = "covered" if inherit_covered_at else "surfaced"
|
|
998
|
+
conn = _connect()
|
|
999
|
+
try:
|
|
1000
|
+
if run_ref:
|
|
1001
|
+
existing = conn.execute(
|
|
1002
|
+
"SELECT * FROM discovery_topics WHERE normalized_name = ?",
|
|
1003
|
+
(normalized,),
|
|
1004
|
+
).fetchone()
|
|
1005
|
+
if existing is not None and existing["last_run_ref"] == run_ref:
|
|
1006
|
+
return dict(existing)
|
|
1007
|
+
conn.execute(
|
|
1008
|
+
"""INSERT INTO discovery_topics
|
|
1009
|
+
(name, normalized_name, entity_key, domain, first_surfaced,
|
|
1010
|
+
last_surfaced, surface_count, last_run_ref, status, covered_at)
|
|
1011
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
1012
|
+
ON CONFLICT(normalized_name) DO UPDATE SET
|
|
1013
|
+
surface_count = surface_count + 1,
|
|
1014
|
+
last_surfaced = excluded.last_surfaced,
|
|
1015
|
+
last_run_ref = excluded.last_run_ref,
|
|
1016
|
+
domain = CASE WHEN excluded.domain <> '' THEN excluded.domain ELSE domain END""",
|
|
1017
|
+
(name, normalized, entity_key, domain, as_of, as_of, run_ref, status, inherit_covered_at),
|
|
1018
|
+
)
|
|
1019
|
+
conn.commit()
|
|
1020
|
+
row = conn.execute(
|
|
1021
|
+
"SELECT * FROM discovery_topics WHERE normalized_name = ?",
|
|
1022
|
+
(normalized,),
|
|
1023
|
+
).fetchone()
|
|
1024
|
+
return dict(row)
|
|
1025
|
+
finally:
|
|
1026
|
+
conn.close()
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def match_discovery_topic(name: str) -> Optional[Dict[str, Any]]:
|
|
1030
|
+
"""Find the queue row a topic name refers to, or None.
|
|
1031
|
+
|
|
1032
|
+
Exact normalized-name match wins; otherwise the best entity-overlap match
|
|
1033
|
+
at or above DISCOVERY_QUEUE_OVERLAP_THRESHOLD. Overlap is the better of
|
|
1034
|
+
the full entity_key token overlap and the anchor-token overlap (see
|
|
1035
|
+
_discovery_anchor_entities) - full-token overlap alone dilutes the subject
|
|
1036
|
+
anchor with generic words, so same-subject near-duplicates would never
|
|
1037
|
+
clear a conservative floor. Matching NEVER merges rows: a fuzzy match only
|
|
1038
|
+
annotates the incoming topic with the prior row's context.
|
|
1039
|
+
"""
|
|
1040
|
+
init_db()
|
|
1041
|
+
normalized = _normalize_discovery_name(name)
|
|
1042
|
+
conn = _connect()
|
|
1043
|
+
try:
|
|
1044
|
+
row = conn.execute(
|
|
1045
|
+
"SELECT * FROM discovery_topics WHERE normalized_name = ?",
|
|
1046
|
+
(normalized,),
|
|
1047
|
+
).fetchone()
|
|
1048
|
+
if row:
|
|
1049
|
+
return dict(row)
|
|
1050
|
+
|
|
1051
|
+
entities = entity_extract.extract_text_entities(name)
|
|
1052
|
+
anchors = _discovery_anchor_entities(name)
|
|
1053
|
+
if not entities and not anchors:
|
|
1054
|
+
return None
|
|
1055
|
+
best: Optional[sqlite3.Row] = None
|
|
1056
|
+
best_overlap = 0.0
|
|
1057
|
+
for candidate in conn.execute("SELECT * FROM discovery_topics").fetchall():
|
|
1058
|
+
candidate_entities = set((candidate["entity_key"] or "").split())
|
|
1059
|
+
overlap = max(
|
|
1060
|
+
entity_extract.entity_overlap(entities, candidate_entities),
|
|
1061
|
+
entity_extract.entity_overlap(
|
|
1062
|
+
anchors, _discovery_anchor_entities(candidate["name"])
|
|
1063
|
+
),
|
|
1064
|
+
)
|
|
1065
|
+
if overlap > best_overlap:
|
|
1066
|
+
best, best_overlap = candidate, overlap
|
|
1067
|
+
if best is not None and best_overlap >= DISCOVERY_QUEUE_OVERLAP_THRESHOLD:
|
|
1068
|
+
return dict(best)
|
|
1069
|
+
return None
|
|
1070
|
+
finally:
|
|
1071
|
+
conn.close()
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def list_discovery_queue(status: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
1075
|
+
"""List queue rows, newest surfacing first, optionally filtered by status."""
|
|
1076
|
+
init_db()
|
|
1077
|
+
conn = _connect()
|
|
1078
|
+
try:
|
|
1079
|
+
if status:
|
|
1080
|
+
rows = conn.execute(
|
|
1081
|
+
"""SELECT * FROM discovery_topics WHERE status = ?
|
|
1082
|
+
ORDER BY last_surfaced DESC, id DESC""",
|
|
1083
|
+
(status,),
|
|
1084
|
+
).fetchall()
|
|
1085
|
+
else:
|
|
1086
|
+
rows = conn.execute(
|
|
1087
|
+
"SELECT * FROM discovery_topics ORDER BY last_surfaced DESC, id DESC"
|
|
1088
|
+
).fetchall()
|
|
1089
|
+
return [dict(r) for r in rows]
|
|
1090
|
+
finally:
|
|
1091
|
+
conn.close()
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def mark_discovery_covered(name: str, as_of: str) -> Optional[Dict[str, Any]]:
|
|
1095
|
+
"""Mark a queued topic covered by EXACT normalized name.
|
|
1096
|
+
|
|
1097
|
+
Returns the updated row, or None when no row matches - callers must error
|
|
1098
|
+
loudly on None, never silently no-op. Fuzzy matching is deliberately not
|
|
1099
|
+
offered here: covering mutates state, so it demands the exact name.
|
|
1100
|
+
"""
|
|
1101
|
+
init_db()
|
|
1102
|
+
normalized = _normalize_discovery_name(name)
|
|
1103
|
+
conn = _connect()
|
|
1104
|
+
try:
|
|
1105
|
+
cursor = conn.execute(
|
|
1106
|
+
"""UPDATE discovery_topics
|
|
1107
|
+
SET status = 'covered', covered_at = ?
|
|
1108
|
+
WHERE normalized_name = ?""",
|
|
1109
|
+
(as_of, normalized),
|
|
1110
|
+
)
|
|
1111
|
+
conn.commit()
|
|
1112
|
+
if cursor.rowcount == 0:
|
|
1113
|
+
return None
|
|
1114
|
+
row = conn.execute(
|
|
1115
|
+
"SELECT * FROM discovery_topics WHERE normalized_name = ?",
|
|
1116
|
+
(normalized,),
|
|
1117
|
+
).fetchone()
|
|
1118
|
+
return dict(row)
|
|
1119
|
+
finally:
|
|
1120
|
+
conn.close()
|
|
1121
|
+
|
|
1122
|
+
|
|
743
1123
|
# --- Cost Tracking ---
|
|
744
1124
|
|
|
745
1125
|
|
|
@@ -763,6 +1143,18 @@ def get_daily_cost(date: Optional[str] = None) -> float:
|
|
|
763
1143
|
# --- Settings ---
|
|
764
1144
|
|
|
765
1145
|
|
|
1146
|
+
def get_daily_unknown_cost_runs(date: Optional[str] = None) -> int:
|
|
1147
|
+
conn = _connect()
|
|
1148
|
+
try:
|
|
1149
|
+
date = date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
1150
|
+
return conn.execute(
|
|
1151
|
+
"SELECT COUNT(*) FROM research_runs WHERE date(run_date) = date(?) AND cost_unknown = 1",
|
|
1152
|
+
(date,),
|
|
1153
|
+
).fetchone()[0]
|
|
1154
|
+
finally:
|
|
1155
|
+
conn.close()
|
|
1156
|
+
|
|
1157
|
+
|
|
766
1158
|
def get_setting(key: str, default: Optional[str] = None) -> Optional[str]:
|
|
767
1159
|
"""Get a setting value."""
|
|
768
1160
|
init_db()
|
|
@@ -852,11 +1244,13 @@ def get_trending(days: int = 7) -> List[Dict[str, Any]]:
|
|
|
852
1244
|
try:
|
|
853
1245
|
since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
854
1246
|
rows = conn.execute(
|
|
855
|
-
"""
|
|
1247
|
+
f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
|
|
1248
|
+
SELECT t.name, t.id,
|
|
856
1249
|
COUNT(f.id) as new_findings,
|
|
857
1250
|
COALESCE(SUM(f.engagement_score), 0) as total_engagement
|
|
858
1251
|
FROM topics t
|
|
859
|
-
LEFT JOIN
|
|
1252
|
+
LEFT JOIN memberships m ON m.topic_id = t.id
|
|
1253
|
+
LEFT JOIN findings f ON f.id = m.finding_id AND m.first_seen >= ?
|
|
860
1254
|
WHERE t.enabled = 1
|
|
861
1255
|
GROUP BY t.id
|
|
862
1256
|
ORDER BY new_findings DESC""",
|