Compare commits
14 commits
c24296c15d
...
fe78a2e42d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe78a2e42d | ||
|
|
63686962c7 | ||
| 56e6755096 | |||
| b2babf1352 | |||
| 7398646248 | |||
|
|
2c6f75ec86 | ||
|
|
740c9a7da6 | ||
|
|
a53f723244 | ||
|
|
7432c4b62e | ||
|
|
29d3a5804f | ||
|
|
a38e5e412a | ||
|
|
794063c8ac | ||
|
|
f77746821d | ||
|
|
08dc7e6ff9 |
16 changed files with 284 additions and 106 deletions
10
.github/workflows/sync-graph-data.yml
vendored
10
.github/workflows/sync-graph-data.yml
vendored
|
|
@ -5,15 +5,7 @@ name: Sync Graph Data to teleo-app
|
||||||
# This triggers a Vercel rebuild automatically.
|
# This triggers a Vercel rebuild automatically.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch: # manual trigger only — disabled auto-run until TELEO_APP_TOKEN is configured
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'core/**'
|
|
||||||
- 'domains/**'
|
|
||||||
- 'foundations/**'
|
|
||||||
- 'convictions/**'
|
|
||||||
- 'ops/extract-graph-data.py'
|
|
||||||
workflow_dispatch: # manual trigger
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
type: claim
|
||||||
|
domain: entertainment
|
||||||
|
description: Exponential cost reduction trajectory creates structural shift where production capability becomes universally accessible within 3-4 years
|
||||||
|
confidence: experimental
|
||||||
|
source: MindStudio, 2026 AI filmmaking cost data
|
||||||
|
created: 2026-04-14
|
||||||
|
title: "AI production cost decline of 60% annually makes feature-film-quality production accessible at consumer price points by 2029"
|
||||||
|
agent: clay
|
||||||
|
scope: structural
|
||||||
|
sourcer: MindStudio
|
||||||
|
related_claims: ["[[non-ATL production costs will converge with the cost of compute as AI replaces labor across the production chain]]"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI production cost decline of 60% annually makes feature-film-quality production accessible at consumer price points by 2029
|
||||||
|
|
||||||
|
GenAI rendering costs are declining approximately 60% annually, with scene generation costs already 90% lower than prior baseline by 2025. At this rate, costs halve every ~18 months. Current data shows 3-minute AI short films cost $75-175 versus $5,000-30,000 for traditional professional production (97-99% reduction), and a feature-length animated film was produced by 9 people in 3 months for ~$700,000 versus typical DreamWorks budgets of $70M-200M (99%+ reduction). Extrapolating the 60%/year trajectory: if a feature film costs $700K today, it will cost ~$280K in 18 months, ~$112K in 3 years, and ~$45K in 4.5 years. This crosses the threshold where individual creators can self-finance feature-length production without institutional backing. The exponential rate is the critical factor—this is not incremental improvement but a Moore's Law-style collapse that makes production capability a non-scarce resource within a single product development cycle.
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
type: claim
|
||||||
|
domain: entertainment
|
||||||
|
description: Cost concentration shifts from technical production to legal/rights as AI collapses labor costs, inverting the current production economics model
|
||||||
|
confidence: experimental
|
||||||
|
source: MindStudio, 2026 AI filmmaking analysis
|
||||||
|
created: 2026-04-14
|
||||||
|
title: IP rights management becomes dominant cost in content production as technical costs approach zero
|
||||||
|
agent: clay
|
||||||
|
scope: structural
|
||||||
|
sourcer: MindStudio
|
||||||
|
related_claims: ["[[non-ATL production costs will converge with the cost of compute as AI replaces labor across the production chain]]", "[[the media attractor state is community-filtered IP with AI-collapsed production costs where content becomes a loss leader for the scarce complements of fandom community and ownership]]"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# IP rights management becomes dominant cost in content production as technical costs approach zero
|
||||||
|
|
||||||
|
As AI production costs collapse toward zero, the primary cost consideration is shifting to rights management—IP licensing, music rights, voice rights—rather than technical production. This represents a fundamental inversion of production economics: historically, technical production (labor, equipment, post-production) dominated costs while rights were a smaller line item. In the AI era, scene complexity is decoupled from cost—a complex VFX sequence costs the same as a simple dialogue scene in compute terms. The implication is that 'cost' of production is becoming a legal/rights problem, not a technical problem. If production costs decline 60% annually while rights costs remain constant or increase (due to scarcity), rights will dominate the cost structure within 2-3 years. This shifts competitive advantage from production capability to IP ownership and rights management expertise. Studios with large IP libraries gain structural advantage not from production infrastructure but from owning the rights that become the primary cost input.
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
type: claim
|
||||||
|
domain: entertainment
|
||||||
|
description: Pudgy Penguins demonstrates commercial IP success with cute characters and financial alignment but minimal world-building or narrative investment
|
||||||
|
confidence: experimental
|
||||||
|
source: CoinDesk Research, Luca Netz revenue confirmation, TheSoul Publishing partnership
|
||||||
|
created: 2026-04-14
|
||||||
|
title: Minimum viable narrative achieves $50M+ revenue scale through character design and distribution without story depth
|
||||||
|
agent: clay
|
||||||
|
scope: causal
|
||||||
|
sourcer: CoinDesk Research
|
||||||
|
related_claims: ["[[minimum-viable-narrative-strategy-optimizes-for-commercial-scale-through-volume-production-and-distribution-coverage-over-story-depth]]", "[[royalty-based-financial-alignment-may-be-sufficient-for-commercial-ip-success-without-narrative-depth]]", "[[distributed-narrative-architecture-enables-ip-scale-without-concentrated-story-through-blank-canvas-fan-projection]]"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Minimum viable narrative achieves $50M+ revenue scale through character design and distribution without story depth
|
||||||
|
|
||||||
|
Pudgy Penguins achieved ~$50M revenue in 2025 with minimal narrative investment, challenging assumptions about story depth requirements for commercial IP success. Characters exist (Atlas, Eureka, Snofia, Springer) but world-building is minimal. The Lil Pudgys animated series partnership with TheSoul Publishing (parent company of 5-Minute Crafts) follows a volume-production model rather than quality-first narrative investment. This is a 'minimum viable narrative' test: cute character design + financial alignment (NFT royalties) + retail distribution penetration (10,000+ locations) = commercial scale without meaningful story. The company targets $120M revenue in 2026 and IPO by 2027 while maintaining this production philosophy. This is NOT evidence that minimal narrative produces civilizational coordination or deep fandom—it's evidence that commercial licensing buyers and retail consumers will purchase IP based on character appeal and distribution coverage alone. The boundary condition: this works for commercial scale but may not work for cultural depth or long-term community sustainability.
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
type: claim
|
||||||
|
domain: entertainment
|
||||||
|
description: Unlike BAYC/Azuki's exclusive-community-first approach, Pudgy Penguins builds global IP through retail and viral content first, then adds NFT layer
|
||||||
|
confidence: experimental
|
||||||
|
source: CoinDesk Research, Luca Netz CEO confirmation
|
||||||
|
created: 2026-04-14
|
||||||
|
title: Pudgy Penguins inverts Web3 IP strategy by prioritizing mainstream distribution before community building
|
||||||
|
agent: clay
|
||||||
|
scope: structural
|
||||||
|
sourcer: CoinDesk Research
|
||||||
|
related_claims: ["[[community-owned-IP-grows-through-complex-contagion-not-viral-spread-because-fandom-requires-multiple-reinforcing-exposures-from-trusted-community-members]]", "[[progressive validation through community building reduces development risk by proving audience demand before production investment]]", "[[the media attractor state is community-filtered IP with AI-collapsed production costs where content becomes a loss leader for the scarce complements of fandom community and ownership]]"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pudgy Penguins inverts Web3 IP strategy by prioritizing mainstream distribution before community building
|
||||||
|
|
||||||
|
Pudgy Penguins explicitly inverts the standard Web3 IP playbook. While Bored Ape Yacht Club and Azuki built exclusive NFT communities first and then attempted mainstream adoption, Pudgy Penguins prioritized physical retail distribution (2M+ Schleich figurines across 3,100 Walmart stores, 10,000+ retail locations) and viral content (79.5B GIPHY views) to acquire users through traditional consumer channels. CEO Luca Netz frames this as 'build a global IP that has an NFT, rather than being an NFT collection trying to become a brand.' This strategy achieved ~$50M revenue in 2025 with a 2026 target of $120M, demonstrating commercial viability of the mainstream-first approach. The inversion is structural: community-first models use exclusivity as the initial value proposition and face friction when broadening; mainstream-first models use accessibility as the initial value proposition and add financial alignment later. This represents a fundamental strategic fork in Web3 IP development, where the sequencing of community vs. mainstream determines the entire go-to-market architecture.
|
||||||
|
|
@ -1,49 +1,52 @@
|
||||||
# Pudgy Penguins
|
# Pudgy Penguins
|
||||||
|
|
||||||
**Type:** Company
|
**Type:** Web3 IP / Consumer Brand
|
||||||
**Domain:** Entertainment
|
**Founded:** 2021 (NFT collection), restructured 2022 under Luca Netz
|
||||||
**Status:** Active
|
**CEO:** Luca Netz
|
||||||
**Founded:** 2021 (NFT collection), 2024 (corporate entity under Luca Netz)
|
**Domain:** Entertainment, Consumer Products
|
||||||
|
**Status:** Active, targeting IPO 2027
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Pudgy Penguins is a community-owned IP project that originated as an NFT collection and evolved into a multi-platform entertainment brand. Under CEO Luca Netz, the company pivoted from 'selling jpegs' to building a global consumer IP platform through mainstream retail distribution, viral social media content, and hidden blockchain infrastructure.
|
Pudgy Penguins is a Web3 IP company that inverted the standard NFT-to-brand strategy by prioritizing mainstream retail distribution and viral content before community building. The company positions itself as "a global IP that has an NFT, rather than being an NFT collection trying to become a brand."
|
||||||
|
|
||||||
## Business Model
|
## Business Model
|
||||||
|
|
||||||
- **Retail Distribution:** 2M+ Schleich figurines across 10,000+ retail locations including 3,100 Walmart stores
|
**Revenue Streams:**
|
||||||
- **Digital Media:** 79.5B GIPHY views (reportedly outperforms Disney and Pokémon per upload)
|
- Physical retail products (Schleich figurines, trading cards)
|
||||||
- **Web3 Infrastructure:** Pudgy World game (launched March 9, 2026), PENGU token, NFT collections
|
- NFT royalties and secondary sales
|
||||||
- **Content Production:** Lil Pudgys animated series (1,000+ minutes self-financed)
|
- Licensing partnerships
|
||||||
|
- Digital collectibles (Pengu Card)
|
||||||
|
|
||||||
## Strategic Approach
|
**Distribution Strategy:**
|
||||||
|
- Retail-first approach: 10,000+ retail locations globally
|
||||||
|
- Viral content: 79.5B GIPHY views (reportedly outperforms Disney/Pokémon per upload in reaction gif category)
|
||||||
|
- Physical products as primary customer acquisition channel
|
||||||
|
|
||||||
**Minimum Viable Narrative:** Partnership with TheSoul Publishing (parent of 5-Minute Crafts) for high-volume content production rather than narrative-focused studios. Characters described as 'four penguin roommates with basic personalities' in 'UnderBerg' setting.
|
## Key Metrics (2025-2026)
|
||||||
|
|
||||||
**Hiding Blockchain:** Deliberately designed consumer-facing products to hide crypto elements. CoinDesk noted Pudgy World 'doesn't feel like crypto at all.' Blockchain treated as invisible infrastructure.
|
- **2025 Revenue:** ~$50M (CEO confirmed)
|
||||||
|
- **2026 Target:** $120M
|
||||||
|
- **Retail Distribution:** 2M+ Schleich figurines sold, 3,100 Walmart stores
|
||||||
|
- **Vibes TCG:** 4M cards sold
|
||||||
|
- **Pengu Card:** Available in 170+ countries
|
||||||
|
- **GIPHY Views:** 79.5B total
|
||||||
|
|
||||||
**Mainstream-First Acquisition:** Acquire users through viral media and retail before Web3 onboarding, inverting typical crypto project trajectory.
|
## Strategic Positioning
|
||||||
|
|
||||||
## Financial Trajectory
|
Unlike Bored Ape Yacht Club and Azuki, which built exclusive NFT communities first and then aimed for mainstream adoption, Pudgy Penguins inverted the sequence: mainstream distribution and viral content first, with NFT/blockchain as invisible infrastructure layer.
|
||||||
|
|
||||||
- **2026 Revenue Target:** $50M-$120M (sources vary)
|
## Content Production
|
||||||
- **IPO Target:** 2027 (Luca Netz stated he'd be 'disappointed' without IPO within 2 years)
|
|
||||||
- **Pengu Card:** Operating in 170+ countries
|
|
||||||
|
|
||||||
## Key Personnel
|
**Narrative Approach:** Minimum viable narrative—characters exist (Atlas, Eureka, Snofia, Springer) but minimal world-building investment.
|
||||||
|
|
||||||
- **Luca Netz:** CEO, architect of pivot from NFT project to consumer brand
|
**Animation Partnership:** Lil Pudgys series produced with TheSoul Publishing (parent company of 5-Minute Crafts), following volume-production model rather than quality-first approach.
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
- **2021** — Pudgy Penguins NFT collection launched
|
- **2021** — Original Pudgy Penguins NFT collection launched
|
||||||
- **2024** — Luca Netz acquires project, pivots strategy toward mainstream consumer brand
|
- **2022** — Luca Netz acquires project and restructures strategy
|
||||||
- **2025-02** — Lil Pudgys animated series announced with TheSoul Publishing partnership
|
- **2024** — Schleich figurine partnership launches, achieving mass retail distribution
|
||||||
- **2026-03-09** — Pudgy World game launched with hidden blockchain infrastructure
|
- **2025** — Achieved ~$50M revenue; Vibes TCG launches with 4M cards sold
|
||||||
- **2026** — 2M+ Schleich figurines sold across 10,000+ retail locations; 79.5B GIPHY views achieved
|
- **2026-02** — CoinDesk Research deep-dive published; company targeting $120M revenue
|
||||||
|
- **2027** — Target IPO date (CEO stated: "I'd be disappointed in myself if we don't IPO in the next two years")
|
||||||
## Sources
|
|
||||||
|
|
||||||
- Animation Magazine (2025-02): Lil Pudgys series announcement
|
|
||||||
- CoinDesk: Strategic framing and Pudgy World review
|
|
||||||
- kidscreen: Retail distribution and financial targets
|
|
||||||
|
|
@ -7,9 +7,12 @@ date: 2026-02-01
|
||||||
domain: entertainment
|
domain: entertainment
|
||||||
secondary_domains: [internet-finance]
|
secondary_domains: [internet-finance]
|
||||||
format: article
|
format: article
|
||||||
status: unprocessed
|
status: processed
|
||||||
|
processed_by: clay
|
||||||
|
processed_date: 2026-04-14
|
||||||
priority: high
|
priority: high
|
||||||
tags: [pudgy-penguins, community-owned-ip, tokenized-culture, web3-ip, commercial-scale, minimum-viable-narrative]
|
tags: [pudgy-penguins, community-owned-ip, tokenized-culture, web3-ip, commercial-scale, minimum-viable-narrative]
|
||||||
|
extraction_model: "anthropic/claude-sonnet-4.5"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
@ -7,9 +7,12 @@ date: 2026-03-01
|
||||||
domain: entertainment
|
domain: entertainment
|
||||||
secondary_domains: []
|
secondary_domains: []
|
||||||
format: article
|
format: article
|
||||||
status: unprocessed
|
status: processed
|
||||||
|
processed_by: clay
|
||||||
|
processed_date: 2026-04-14
|
||||||
priority: high
|
priority: high
|
||||||
tags: [AI-production, cost-collapse, independent-film, GenAI, progressive-control, production-economics]
|
tags: [AI-production, cost-collapse, independent-film, GenAI, progressive-control, production-economics]
|
||||||
|
extraction_model: "anthropic/claude-sonnet-4.5"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
@ -7,9 +7,10 @@ date: 2026-04-14
|
||||||
domain: space-development
|
domain: space-development
|
||||||
secondary_domains: []
|
secondary_domains: []
|
||||||
format: article
|
format: article
|
||||||
status: unprocessed
|
status: null-result
|
||||||
priority: high
|
priority: high
|
||||||
tags: [Blue-Origin, New-Glenn, NG-3, booster-reuse, AST-SpaceMobile, BlueBird, execution-gap, Pattern-2]
|
tags: [Blue-Origin, New-Glenn, NG-3, booster-reuse, AST-SpaceMobile, BlueBird, execution-gap, Pattern-2]
|
||||||
|
extraction_model: "anthropic/claude-sonnet-4.5"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
@ -7,9 +7,10 @@ date: 2026-04-01
|
||||||
domain: space-development
|
domain: space-development
|
||||||
secondary_domains: [energy]
|
secondary_domains: [energy]
|
||||||
format: article
|
format: article
|
||||||
status: unprocessed
|
status: null-result
|
||||||
priority: medium
|
priority: medium
|
||||||
tags: [orbital-data-centers, SpaceX, feasibility, physics-critique, thermal-management, power-density, refrigeration]
|
tags: [orbital-data-centers, SpaceX, feasibility, physics-critique, thermal-management, power-density, refrigeration]
|
||||||
|
extraction_model: "anthropic/claude-sonnet-4.5"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
@ -7,9 +7,10 @@ date: 2026-03-15
|
||||||
domain: entertainment
|
domain: entertainment
|
||||||
secondary_domains: []
|
secondary_domains: []
|
||||||
format: article
|
format: article
|
||||||
status: unprocessed
|
status: null-result
|
||||||
priority: medium
|
priority: medium
|
||||||
tags: [entertainment-industry, business-reset, smaller-budgets, quality-over-volume, AI-efficiency, slope-reading]
|
tags: [entertainment-industry, business-reset, smaller-budgets, quality-over-volume, AI-efficiency, slope-reading]
|
||||||
|
extraction_model: "anthropic/claude-sonnet-4.5"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
@ -7,9 +7,10 @@ date: 2026-04-01
|
||||||
domain: entertainment
|
domain: entertainment
|
||||||
secondary_domains: []
|
secondary_domains: []
|
||||||
format: article
|
format: article
|
||||||
status: unprocessed
|
status: null-result
|
||||||
priority: low
|
priority: low
|
||||||
tags: [tariffs, creator-economy, production-costs, equipment, AI-substitution, macroeconomics]
|
tags: [tariffs, creator-economy, production-costs, equipment, AI-substitution, macroeconomics]
|
||||||
|
extraction_model: "anthropic/claude-sonnet-4.5"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
@ -7,9 +7,10 @@ date: 2026-04-01
|
||||||
domain: entertainment
|
domain: entertainment
|
||||||
secondary_domains: []
|
secondary_domains: []
|
||||||
format: article
|
format: article
|
||||||
status: unprocessed
|
status: null-result
|
||||||
priority: medium
|
priority: medium
|
||||||
tags: [hollywood, layoffs, AI-displacement, jobs, disruption, slope-reading]
|
tags: [hollywood, layoffs, AI-displacement, jobs, disruption, slope-reading]
|
||||||
|
extraction_model: "anthropic/claude-sonnet-4.5"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
@ -67,6 +67,8 @@ def check_agent_health(conn: sqlite3.Connection) -> list[dict]:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
agent = r["agent"]
|
agent = r["agent"]
|
||||||
|
if agent in ("unknown", None):
|
||||||
|
continue
|
||||||
latest = r["latest"]
|
latest = r["latest"]
|
||||||
if not latest:
|
if not latest:
|
||||||
continue
|
continue
|
||||||
|
|
@ -266,24 +268,22 @@ def check_rejection_spike(conn: sqlite3.Connection) -> list[dict]:
|
||||||
"""Detect single rejection reason exceeding REJECTION_SPIKE_RATIO of recent rejections."""
|
"""Detect single rejection reason exceeding REJECTION_SPIKE_RATIO of recent rejections."""
|
||||||
alerts = []
|
alerts = []
|
||||||
|
|
||||||
# Total rejections in 24h
|
# Total rejected PRs in 24h (prs.eval_issues is the canonical source — Epimetheus 2026-04-02)
|
||||||
total = conn.execute(
|
total = conn.execute(
|
||||||
"""SELECT COUNT(*) as n FROM audit_log
|
"""SELECT COUNT(*) as n FROM prs
|
||||||
WHERE stage='evaluate'
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||||
AND event IN ('changes_requested','domain_rejected','tier05_rejected')
|
AND created_at > datetime('now', '-24 hours')"""
|
||||||
AND timestamp > datetime('now', '-24 hours')"""
|
|
||||||
).fetchone()["n"]
|
).fetchone()["n"]
|
||||||
|
|
||||||
if total < 10:
|
if total < 10:
|
||||||
return alerts # Not enough data
|
return alerts # Not enough data
|
||||||
|
|
||||||
# Count by rejection tag
|
# Count by rejection tag from prs.eval_issues
|
||||||
tags = conn.execute(
|
tags = conn.execute(
|
||||||
"""SELECT value as tag, COUNT(*) as cnt
|
"""SELECT value as tag, COUNT(*) as cnt
|
||||||
FROM audit_log, json_each(json_extract(detail, '$.issues'))
|
FROM prs, json_each(prs.eval_issues)
|
||||||
WHERE stage='evaluate'
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||||
AND event IN ('changes_requested','domain_rejected','tier05_rejected')
|
AND created_at > datetime('now', '-24 hours')
|
||||||
AND timestamp > datetime('now', '-24 hours')
|
|
||||||
GROUP BY tag ORDER BY cnt DESC"""
|
GROUP BY tag ORDER BY cnt DESC"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
|
@ -315,16 +315,13 @@ def check_stuck_loops(conn: sqlite3.Connection) -> list[dict]:
|
||||||
"""Detect agents repeatedly failing on the same rejection reason."""
|
"""Detect agents repeatedly failing on the same rejection reason."""
|
||||||
alerts = []
|
alerts = []
|
||||||
|
|
||||||
# COALESCE: rejection events use $.agent, eval events use $.domain_agent (Epimetheus 2026-03-28)
|
# Agent + rejection reason from prs table directly (Epimetheus correction 2026-04-02)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent')) as agent,
|
"""SELECT agent, value as tag, COUNT(*) as cnt
|
||||||
value as tag,
|
FROM prs, json_each(prs.eval_issues)
|
||||||
COUNT(*) as cnt
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||||
FROM audit_log, json_each(json_extract(detail, '$.issues'))
|
AND agent IS NOT NULL
|
||||||
WHERE stage='evaluate'
|
AND created_at > datetime('now', '-6 hours')
|
||||||
AND event IN ('changes_requested','domain_rejected','tier05_rejected')
|
|
||||||
AND timestamp > datetime('now', '-6 hours')
|
|
||||||
AND COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent')) IS NOT NULL
|
|
||||||
GROUP BY agent, tag
|
GROUP BY agent, tag
|
||||||
HAVING cnt > ?""",
|
HAVING cnt > ?""",
|
||||||
(STUCK_LOOP_THRESHOLD,),
|
(STUCK_LOOP_THRESHOLD,),
|
||||||
|
|
@ -412,16 +409,13 @@ def check_domain_rejection_patterns(conn: sqlite3.Connection) -> list[dict]:
|
||||||
"""Track rejection reason shift per domain — surfaces domain maturity issues."""
|
"""Track rejection reason shift per domain — surfaces domain maturity issues."""
|
||||||
alerts = []
|
alerts = []
|
||||||
|
|
||||||
# Per-domain rejection breakdown in 24h
|
# Per-domain rejection breakdown in 24h from prs table (Epimetheus correction 2026-04-02)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT json_extract(detail, '$.domain') as domain,
|
"""SELECT domain, value as tag, COUNT(*) as cnt
|
||||||
value as tag,
|
FROM prs, json_each(prs.eval_issues)
|
||||||
COUNT(*) as cnt
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||||
FROM audit_log, json_each(json_extract(detail, '$.issues'))
|
AND domain IS NOT NULL
|
||||||
WHERE stage='evaluate'
|
AND created_at > datetime('now', '-24 hours')
|
||||||
AND event IN ('changes_requested','domain_rejected','tier05_rejected')
|
|
||||||
AND timestamp > datetime('now', '-24 hours')
|
|
||||||
AND json_extract(detail, '$.domain') IS NOT NULL
|
|
||||||
GROUP BY domain, tag
|
GROUP BY domain, tag
|
||||||
ORDER BY domain, cnt DESC"""
|
ORDER BY domain, cnt DESC"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
@ -473,12 +467,11 @@ def generate_failure_report(conn: sqlite3.Connection, agent: str, hours: int = 2
|
||||||
hours = int(hours) # defensive — callers should pass int, but enforce it
|
hours = int(hours) # defensive — callers should pass int, but enforce it
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT value as tag, COUNT(*) as cnt,
|
"""SELECT value as tag, COUNT(*) as cnt,
|
||||||
GROUP_CONCAT(DISTINCT json_extract(detail, '$.pr')) as pr_numbers
|
GROUP_CONCAT(DISTINCT number) as pr_numbers
|
||||||
FROM audit_log, json_each(json_extract(detail, '$.issues'))
|
FROM prs, json_each(prs.eval_issues)
|
||||||
WHERE stage='evaluate'
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||||
AND event IN ('changes_requested','domain_rejected','tier05_rejected')
|
AND agent = ?
|
||||||
AND json_extract(detail, '$.agent') = ?
|
AND created_at > datetime('now', ? || ' hours')
|
||||||
AND timestamp > datetime('now', ? || ' hours')
|
|
||||||
GROUP BY tag ORDER BY cnt DESC
|
GROUP BY tag ORDER BY cnt DESC
|
||||||
LIMIT 5""",
|
LIMIT 5""",
|
||||||
(agent, f"-{hours}"),
|
(agent, f"-{hours}"),
|
||||||
|
|
|
||||||
|
|
@ -194,12 +194,6 @@ fetch('/api/review-summary?days=30')
|
||||||
reasonRows += '<tr><td><code>' + esc(r.reason) + '</code></td><td>' + r.count + '</td></tr>';
|
reasonRows += '<tr><td><code>' + esc(r.reason) + '</code></td><td>' + r.count + '</td></tr>';
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Disagreement types
|
|
||||||
let disagreeRows = '';
|
|
||||||
for (const d of (data.disagreement_types || [])) {{
|
|
||||||
disagreeRows += '<tr><td>' + esc(d.type) + '</td><td>' + d.count + '</td></tr>';
|
|
||||||
}}
|
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card"><div class="label">Total Reviews</div><div class="hero-value">${{data.total}}</div></div>
|
<div class="card"><div class="label">Total Reviews</div><div class="hero-value">${{data.total}}</div></div>
|
||||||
|
|
@ -215,13 +209,6 @@ fetch('/api/review-summary?days=30')
|
||||||
${{reasonRows || '<tr><td colspan="2" style="color:#8b949e">No rejections</td></tr>'}}
|
${{reasonRows || '<tr><td colspan="2" style="color:#8b949e">No rejections</td></tr>'}}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div style="font-weight:600;margin-bottom:8px">Disagreement Types</div>
|
|
||||||
<table>
|
|
||||||
<tr><th>Type</th><th>Count</th></tr>
|
|
||||||
${{disagreeRows || '<tr><td colspan="2" style="color:#8b949e">No disagreements</td></tr>'}}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}}).catch(() => {{
|
}}).catch(() => {{
|
||||||
document.getElementById('review-container').innerHTML =
|
document.getElementById('review-container').innerHTML =
|
||||||
|
|
|
||||||
|
|
@ -237,9 +237,9 @@ async def handle_extraction_yield_by_domain(request):
|
||||||
|
|
||||||
# Sources per domain (approximate from PR source_path domain)
|
# Sources per domain (approximate from PR source_path domain)
|
||||||
source_counts = conn.execute(
|
source_counts = conn.execute(
|
||||||
"""SELECT domain, COUNT(DISTINCT source_url) as sources
|
"""SELECT domain, COUNT(DISTINCT path) as sources
|
||||||
FROM sources s
|
FROM sources s
|
||||||
JOIN prs p ON p.source_path LIKE '%' || s.url || '%'
|
JOIN prs p ON p.source_path LIKE '%' || s.path || '%'
|
||||||
WHERE s.created_at > datetime('now', ? || ' days')
|
WHERE s.created_at > datetime('now', ? || ' days')
|
||||||
GROUP BY domain""",
|
GROUP BY domain""",
|
||||||
(f"-{days}",),
|
(f"-{days}",),
|
||||||
|
|
@ -444,6 +444,8 @@ async def handle_cascade_coverage(request):
|
||||||
for r in triggered
|
for r in triggered
|
||||||
]
|
]
|
||||||
|
|
||||||
|
insufficient_data = total_triggered < 5
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"days": days,
|
"days": days,
|
||||||
"total_triggered": total_triggered,
|
"total_triggered": total_triggered,
|
||||||
|
|
@ -452,6 +454,7 @@ async def handle_cascade_coverage(request):
|
||||||
"total_notifications": summaries["total_notifications"] if summaries else 0,
|
"total_notifications": summaries["total_notifications"] if summaries else 0,
|
||||||
"merges_with_cascade": summaries["total_merges_with_cascade"] if summaries else 0,
|
"merges_with_cascade": summaries["total_merges_with_cascade"] if summaries else 0,
|
||||||
"by_agent": by_agent,
|
"by_agent": by_agent,
|
||||||
|
"insufficient_data": insufficient_data,
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -490,7 +493,7 @@ async def handle_review_summary(request):
|
||||||
(f"-{days}",),
|
(f"-{days}",),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
# Rejection reasons
|
# Rejection reasons — try review_records first, fall back to prs.eval_issues
|
||||||
reasons = conn.execute(
|
reasons = conn.execute(
|
||||||
"""SELECT rejection_reason, COUNT(*) as cnt
|
"""SELECT rejection_reason, COUNT(*) as cnt
|
||||||
FROM review_records
|
FROM review_records
|
||||||
|
|
@ -500,15 +503,17 @@ async def handle_review_summary(request):
|
||||||
(f"-{days}",),
|
(f"-{days}",),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
# Disagreement types
|
rejection_source = "review_records"
|
||||||
disagreements = conn.execute(
|
if not reasons:
|
||||||
"""SELECT disagreement_type, COUNT(*) as cnt
|
reasons = conn.execute(
|
||||||
FROM review_records
|
"""SELECT value AS rejection_reason, COUNT(*) as cnt
|
||||||
WHERE disagreement_type IS NOT NULL
|
FROM prs, json_each(prs.eval_issues)
|
||||||
AND reviewed_at > datetime('now', ? || ' days')
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||||
GROUP BY disagreement_type ORDER BY cnt DESC""",
|
AND created_at > datetime('now', ? || ' days')
|
||||||
(f"-{days}",),
|
GROUP BY value ORDER BY cnt DESC""",
|
||||||
).fetchall()
|
(f"-{days}",),
|
||||||
|
).fetchall()
|
||||||
|
rejection_source = "prs.eval_issues"
|
||||||
|
|
||||||
# Per-reviewer breakdown
|
# Per-reviewer breakdown
|
||||||
reviewers = conn.execute(
|
reviewers = conn.execute(
|
||||||
|
|
@ -541,7 +546,7 @@ async def handle_review_summary(request):
|
||||||
"total": total,
|
"total": total,
|
||||||
"outcomes": {r["outcome"]: r["cnt"] for r in outcomes},
|
"outcomes": {r["outcome"]: r["cnt"] for r in outcomes},
|
||||||
"rejection_reasons": [{"reason": r["rejection_reason"], "count": r["cnt"]} for r in reasons],
|
"rejection_reasons": [{"reason": r["rejection_reason"], "count": r["cnt"]} for r in reasons],
|
||||||
"disagreement_types": [{"type": r["disagreement_type"], "count": r["cnt"]} for r in disagreements],
|
"rejection_source": rejection_source,
|
||||||
"reviewers": [
|
"reviewers": [
|
||||||
{"reviewer": r["reviewer"], "approved": r["approved"], "approved_with_changes": r["approved_with_changes"],
|
{"reviewer": r["reviewer"], "approved": r["approved"], "approved_with_changes": r["approved_with_changes"],
|
||||||
"rejected": r["rejected"], "total": r["total"]}
|
"rejected": r["rejected"], "total": r["total"]}
|
||||||
|
|
@ -557,6 +562,124 @@ async def handle_review_summary(request):
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GET /api/agent-scorecard ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def handle_agent_scorecard(request):
|
||||||
|
"""Per-agent scorecard: PRs submitted, review outcomes, rejection reasons.
|
||||||
|
|
||||||
|
Data from review_records (structured reviews) + prs (submission counts).
|
||||||
|
Falls back to prs.eval_issues for rejection reasons when review_records
|
||||||
|
has no rejections yet.
|
||||||
|
"""
|
||||||
|
conn = request.app["_get_conn"]()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
days = min(int(request.query.get("days", "30")), 90)
|
||||||
|
except ValueError:
|
||||||
|
days = 30
|
||||||
|
day_filter = f"-{days}"
|
||||||
|
|
||||||
|
# PRs submitted per agent
|
||||||
|
prs_by_agent = conn.execute(
|
||||||
|
"""SELECT agent, COUNT(*) as cnt FROM prs
|
||||||
|
WHERE agent IS NOT NULL
|
||||||
|
AND created_at > datetime('now', ? || ' days')
|
||||||
|
GROUP BY agent""",
|
||||||
|
(day_filter,),
|
||||||
|
).fetchall()
|
||||||
|
prs_map = {r["agent"]: r["cnt"] for r in prs_by_agent}
|
||||||
|
|
||||||
|
# Review outcomes from review_records
|
||||||
|
review_data = {}
|
||||||
|
try:
|
||||||
|
reviews = conn.execute(
|
||||||
|
"""SELECT reviewer as agent, outcome, COUNT(*) as cnt
|
||||||
|
FROM review_records
|
||||||
|
WHERE reviewed_at > datetime('now', ? || ' days')
|
||||||
|
GROUP BY reviewer, outcome""",
|
||||||
|
(day_filter,),
|
||||||
|
).fetchall()
|
||||||
|
for r in reviews:
|
||||||
|
agent = r["agent"]
|
||||||
|
if agent not in review_data:
|
||||||
|
review_data[agent] = {"approved": 0, "approved_with_changes": 0, "rejected": 0, "total": 0}
|
||||||
|
review_data[agent][r["outcome"].replace("-", "_")] = r["cnt"]
|
||||||
|
review_data[agent]["total"] += r["cnt"]
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If review_records is empty, fall back to audit_log eval events
|
||||||
|
if not review_data:
|
||||||
|
evals = conn.execute(
|
||||||
|
"""SELECT
|
||||||
|
COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent')) as agent,
|
||||||
|
event, COUNT(*) as cnt
|
||||||
|
FROM audit_log
|
||||||
|
WHERE stage='evaluate'
|
||||||
|
AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected')
|
||||||
|
AND timestamp > datetime('now', ? || ' days')
|
||||||
|
GROUP BY agent, event""",
|
||||||
|
(day_filter,),
|
||||||
|
).fetchall()
|
||||||
|
for r in evals:
|
||||||
|
agent = r["agent"]
|
||||||
|
if not agent:
|
||||||
|
continue
|
||||||
|
if agent not in review_data:
|
||||||
|
review_data[agent] = {"approved": 0, "approved_with_changes": 0, "rejected": 0, "total": 0}
|
||||||
|
if r["event"] == "approved":
|
||||||
|
review_data[agent]["approved"] += r["cnt"]
|
||||||
|
elif r["event"] == "changes_requested": # fixer auto-remediated; equivalent in pre-review_records era
|
||||||
|
review_data[agent]["approved_with_changes"] += r["cnt"]
|
||||||
|
else:
|
||||||
|
review_data[agent]["rejected"] += r["cnt"]
|
||||||
|
review_data[agent]["total"] += r["cnt"]
|
||||||
|
|
||||||
|
# Rejection reasons from prs.eval_issues (canonical source)
|
||||||
|
reason_rows = conn.execute(
|
||||||
|
"""SELECT agent, value as reason, COUNT(*) as cnt
|
||||||
|
FROM prs, json_each(prs.eval_issues)
|
||||||
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||||
|
AND agent IS NOT NULL
|
||||||
|
AND created_at > datetime('now', ? || ' days')
|
||||||
|
GROUP BY agent, reason ORDER BY agent, cnt DESC""",
|
||||||
|
(day_filter,),
|
||||||
|
).fetchall()
|
||||||
|
reasons_map = {}
|
||||||
|
for r in reason_rows:
|
||||||
|
if r["agent"] not in reasons_map:
|
||||||
|
reasons_map[r["agent"]] = {}
|
||||||
|
reasons_map[r["agent"]][r["reason"]] = r["cnt"]
|
||||||
|
|
||||||
|
# Build scorecards
|
||||||
|
all_agents = sorted(set(list(prs_map.keys()) + list(review_data.keys())))
|
||||||
|
scorecards = []
|
||||||
|
for agent in all_agents:
|
||||||
|
if agent in ("unknown", None):
|
||||||
|
continue
|
||||||
|
rd = review_data.get(agent, {"approved": 0, "approved_with_changes": 0, "rejected": 0, "total": 0})
|
||||||
|
total_reviews = rd["total"]
|
||||||
|
approved = rd["approved"]
|
||||||
|
approved_wc = rd["approved_with_changes"]
|
||||||
|
rejected = rd["rejected"]
|
||||||
|
approval_rate = ((approved + approved_wc) / total_reviews * 100) if total_reviews else 0
|
||||||
|
scorecards.append({
|
||||||
|
"agent": agent,
|
||||||
|
"total_prs": prs_map.get(agent, 0),
|
||||||
|
"total_reviews": total_reviews,
|
||||||
|
"approved": approved,
|
||||||
|
"approved_with_changes": approved_wc,
|
||||||
|
"rejected": rejected,
|
||||||
|
"approval_rate": round(approval_rate, 1),
|
||||||
|
"rejection_reasons": reasons_map.get(agent, {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
scorecards.sort(key=lambda x: x["total_reviews"], reverse=True)
|
||||||
|
return web.json_response({"days": days, "scorecards": scorecards})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ─── Trace endpoint ────────────────────────────────────────────────────────
|
# ─── Trace endpoint ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -998,6 +1121,7 @@ def register_dashboard_routes(app: web.Application, get_conn):
|
||||||
app.router.add_get("/api/agents-dashboard", handle_agents_dashboard)
|
app.router.add_get("/api/agents-dashboard", handle_agents_dashboard)
|
||||||
app.router.add_get("/api/cascade-coverage", handle_cascade_coverage)
|
app.router.add_get("/api/cascade-coverage", handle_cascade_coverage)
|
||||||
app.router.add_get("/api/review-summary", handle_review_summary)
|
app.router.add_get("/api/review-summary", handle_review_summary)
|
||||||
|
app.router.add_get("/api/agent-scorecard", handle_agent_scorecard)
|
||||||
app.router.add_get("/api/trace/{trace_id}", handle_trace)
|
app.router.add_get("/api/trace/{trace_id}", handle_trace)
|
||||||
app.router.add_get("/api/growth", handle_growth)
|
app.router.add_get("/api/growth", handle_growth)
|
||||||
app.router.add_get("/api/pr-lifecycle", handle_pr_lifecycle)
|
app.router.add_get("/api/pr-lifecycle", handle_pr_lifecycle)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue