[object Object]

← back to Cli Printing Press

feat(generator): add Non-Obvious Insight system, domain archetype detection, and entity mapping

f5369dd0bfebb99a665f0e23244a88741919f098 · 2026-03-26 09:53:40 -0700 · Matt Van Horn

Phases 0-2 of the Creative Vision Engine:

- NOI System: NonObviousInsight struct in vision/insight.go with Formula() and HasInsight()
- Domain Archetypes: DomainSignals detection in profiler (communication, PM, payments,
  infrastructure, content, CRM, developer-platform) via keyword heuristics on resource
  names and field names
- Entity Mapper: WorkflowTemplateContext + EntityMapping in generator/entity_mapper.go
  that maps API spec resources to their domain roles (primary entity, team, user, state)
- VisionTemplateSet: Workflows and Insights slice fields for conditional template rendering
- NOI Examples: Reference file with 10+ examples across all archetypes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f5369dd0bfebb99a665f0e23244a88741919f098
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Thu Mar 26 09:53:40 2026 -0700

    feat(generator): add Non-Obvious Insight system, domain archetype detection, and entity mapping
    
    Phases 0-2 of the Creative Vision Engine:
    
    - NOI System: NonObviousInsight struct in vision/insight.go with Formula() and HasInsight()
    - Domain Archetypes: DomainSignals detection in profiler (communication, PM, payments,
      infrastructure, content, CRM, developer-platform) via keyword heuristics on resource
      names and field names
    - Entity Mapper: WorkflowTemplateContext + EntityMapping in generator/entity_mapper.go
      that maps API spec resources to their domain roles (primary entity, team, user, state)
    - VisionTemplateSet: Workflows and Insights slice fields for conditional template rendering
    - NOI Examples: Reference file with 10+ examples across all archetypes
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/entity_mapper.go              | 203 +++++++++++++++++++++++
 internal/generator/generator.go                  |   2 +-
 internal/generator/vision_templates.go           |  35 ++++
 internal/profiler/profiler.go                    | 148 +++++++++++++++++
 internal/vision/insight.go                       |  19 +++
 internal/vision/vision.go                        |  12 ++
 skills/printing-press/references/noi-examples.md |  49 ++++++
 7 files changed, 467 insertions(+), 1 deletion(-)

diff --git a/internal/generator/entity_mapper.go b/internal/generator/entity_mapper.go
new file mode 100644
index 00000000..3b8840ab
--- /dev/null
+++ b/internal/generator/entity_mapper.go
@@ -0,0 +1,203 @@
+package generator
+
+import (
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/internal/profiler"
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+type WorkflowTemplateContext struct {
+	CLIName       string
+	APIName       string
+	Domain        string
+	PrimaryEntity EntityMapping
+	TeamEntity    EntityMapping
+	UserEntity    EntityMapping
+	StateEntity   EntityMapping
+	HasAssignees  bool
+	HasDueDates   bool
+	HasPriority   bool
+	HasTeams      bool
+	HasLabels     bool
+	HasEstimates  bool
+}
+
+type EntityMapping struct {
+	TableName      string
+	HumanSingular  string
+	HumanPlural    string
+	IdentifierField string
+	TitleField     string
+	UpdatedAtField string
+	AssigneeField  string
+	PriorityField  string
+	DueDateField   string
+	TeamField      string
+	LabelField     string
+	EstimateField  string
+	StateField     string
+}
+
+func MapEntities(s *spec.APISpec, domain profiler.DomainSignals) WorkflowTemplateContext {
+	ctx := WorkflowTemplateContext{
+		Domain:       string(domain.Archetype),
+		HasAssignees: domain.HasAssignees,
+		HasDueDates:  domain.HasDueDates,
+		HasPriority:  domain.HasPriority,
+		HasTeams:     domain.HasTeams,
+		HasLabels:    domain.HasLabels,
+		HasEstimates: domain.HasEstimates,
+	}
+
+	if s == nil {
+		return ctx
+	}
+
+	ctx.APIName = s.Name
+
+	primaryKeywords := primaryKeywordsForArchetype(domain.Archetype)
+	teamKeywords := []string{"team", "group", "organization", "workspace"}
+	userKeywords := []string{"user", "member", "person", "contact", "account"}
+	stateKeywords := []string{"status", "state", "stage", "phase"}
+
+	for name, resource := range s.Resources {
+		nameLower := strings.ToLower(name)
+
+		if ctx.PrimaryEntity.TableName == "" && matchesKeywords(nameLower, primaryKeywords) {
+			ctx.PrimaryEntity = mapResource(name, resource)
+		}
+		if ctx.TeamEntity.TableName == "" && matchesKeywords(nameLower, teamKeywords) {
+			ctx.TeamEntity = mapResource(name, resource)
+		}
+		if ctx.UserEntity.TableName == "" && matchesKeywords(nameLower, userKeywords) {
+			ctx.UserEntity = mapResource(name, resource)
+		}
+		if ctx.StateEntity.TableName == "" && matchesKeywords(nameLower, stateKeywords) {
+			ctx.StateEntity = mapResource(name, resource)
+		}
+
+		for subName, sub := range resource.SubResources {
+			subLower := strings.ToLower(subName)
+			if ctx.PrimaryEntity.TableName == "" && matchesKeywords(subLower, primaryKeywords) {
+				ctx.PrimaryEntity = mapResource(subName, sub)
+			}
+		}
+	}
+
+	if ctx.PrimaryEntity.TableName != "" {
+		scanEntityFields(s, ctx.PrimaryEntity.TableName, &ctx.PrimaryEntity)
+	}
+
+	return ctx
+}
+
+func primaryKeywordsForArchetype(archetype profiler.DomainArchetype) []string {
+	switch archetype {
+	case profiler.ArchetypeProjectMgmt:
+		return []string{"issue", "task", "ticket", "work_item", "story"}
+	case profiler.ArchetypeCommunication:
+		return []string{"message", "chat", "conversation"}
+	case profiler.ArchetypePayments:
+		return []string{"charge", "payment", "invoice", "transaction"}
+	case profiler.ArchetypeInfrastructure:
+		return []string{"instance", "server", "deployment", "container"}
+	case profiler.ArchetypeContent:
+		return []string{"article", "post", "page", "document"}
+	case profiler.ArchetypeCRM:
+		return []string{"contact", "deal", "lead", "opportunity"}
+	case profiler.ArchetypeDeveloperPlatform:
+		return []string{"repository", "pull_request", "merge_request", "commit"}
+	default:
+		return []string{}
+	}
+}
+
+func matchesKeywords(name string, keywords []string) bool {
+	for _, kw := range keywords {
+		if strings.Contains(name, kw) {
+			return true
+		}
+	}
+	return false
+}
+
+func mapResource(name string, r spec.Resource) EntityMapping {
+	singular := strings.ToLower(name)
+	singular = strings.TrimSuffix(singular, "s")
+
+	plural := singular + "s"
+	if strings.HasSuffix(singular, "y") && !strings.HasSuffix(singular, "ey") {
+		plural = singular[:len(singular)-1] + "ies"
+	}
+
+	em := EntityMapping{
+		TableName:     strings.ReplaceAll(strings.ToLower(name), "-", "_"),
+		HumanSingular: singular,
+		HumanPlural:   plural,
+	}
+
+	for _, endpoint := range r.Endpoints {
+		scanMappingFields(endpoint.Body, &em)
+		scanMappingFields(endpoint.Params, &em)
+	}
+
+	if em.IdentifierField == "" {
+		em.IdentifierField = "id"
+	}
+
+	return em
+}
+
+func scanMappingFields(params []spec.Param, em *EntityMapping) {
+	for _, p := range params {
+		name := strings.ToLower(p.Name)
+
+		if em.IdentifierField == "" && (name == "id" || strings.HasSuffix(name, "_id")) {
+			em.IdentifierField = p.Name
+		}
+		if em.TitleField == "" && (name == "title" || name == "name" || name == "subject" || name == "summary") {
+			em.TitleField = p.Name
+		}
+		if em.UpdatedAtField == "" && (name == "updated_at" || name == "modified_at" || name == "last_modified") {
+			em.UpdatedAtField = p.Name
+		}
+		if em.AssigneeField == "" && (strings.Contains(name, "assignee") || name == "assigned_to") {
+			em.AssigneeField = p.Name
+		}
+		if em.PriorityField == "" && strings.Contains(name, "priority") {
+			em.PriorityField = p.Name
+		}
+		if em.DueDateField == "" && (strings.Contains(name, "due_date") || strings.Contains(name, "due_at") || strings.Contains(name, "deadline")) {
+			em.DueDateField = p.Name
+		}
+		if em.TeamField == "" && (name == "team_id" || name == "team") {
+			em.TeamField = p.Name
+		}
+		if em.LabelField == "" && (strings.Contains(name, "label") || name == "tags") {
+			em.LabelField = p.Name
+		}
+		if em.EstimateField == "" && (strings.Contains(name, "estimate") || strings.Contains(name, "story_points") || name == "points") {
+			em.EstimateField = p.Name
+		}
+		if em.StateField == "" && (name == "status" || name == "state") {
+			em.StateField = p.Name
+		}
+
+		if len(p.Fields) > 0 {
+			scanMappingFields(p.Fields, em)
+		}
+	}
+}
+
+func scanEntityFields(s *spec.APISpec, tableName string, em *EntityMapping) {
+	for name, resource := range s.Resources {
+		if strings.EqualFold(strings.ReplaceAll(strings.ToLower(name), "-", "_"), tableName) {
+			for _, endpoint := range resource.Endpoints {
+				scanMappingFields(endpoint.Body, em)
+				scanMappingFields(endpoint.Params, em)
+			}
+			return
+		}
+	}
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 9a75decf..bb32848b 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -191,7 +191,7 @@ func (g *Generator) Generate() error {
 	}
 
 	// Vision features: profile the API and render selected templates
-	if g.VisionSet == (VisionTemplateSet{}) {
+	if g.VisionSet.IsZero() {
 		// Auto-profile if no explicit vision set provided
 		g.profile = profiler.Profile(g.Spec)
 		plan := g.profile.ToVisionaryPlan(g.Spec.Name)
diff --git a/internal/generator/vision_templates.go b/internal/generator/vision_templates.go
index 1200787c..56cbf2dd 100644
--- a/internal/generator/vision_templates.go
+++ b/internal/generator/vision_templates.go
@@ -13,6 +13,14 @@ type VisionTemplateSet struct {
 	Sync      bool
 	Tail      bool
 	Analytics bool
+	Workflows []string
+	Insights  []string
+}
+
+func (s VisionTemplateSet) IsZero() bool {
+	return !s.Export && !s.Import && !s.Store && !s.Search &&
+		!s.Sync && !s.Tail && !s.Analytics &&
+		len(s.Workflows) == 0 && len(s.Insights) == 0
 }
 
 // SelectVisionTemplates determines which domain-aware templates to include
@@ -91,9 +99,34 @@ func SelectVisionTemplates(plan *vision.VisionaryPlan) VisionTemplateSet {
 		}
 	}
 
+	switch plan.Domain.Archetype {
+	case "project-management":
+		set.Workflows = []string{
+			"workflows/pm_stale.go.tmpl",
+			"workflows/pm_orphans.go.tmpl",
+			"workflows/pm_load.go.tmpl",
+		}
+	case "communication":
+		set.Workflows = []string{
+			"workflows/comm_health.go.tmpl",
+		}
+	}
+
+	if plan.Insight.HasInsight() {
+		set.Insights = []string{"insight_report.go.tmpl"}
+	}
+
 	return set
 }
 
+func (s VisionTemplateSet) HasWorkflows() bool {
+	return len(s.Workflows) > 0
+}
+
+func (s VisionTemplateSet) HasInsights() bool {
+	return len(s.Insights) > 0
+}
+
 // TemplateNames returns the list of template filenames to render.
 func (s VisionTemplateSet) TemplateNames() []string {
 	var names []string
@@ -118,5 +151,7 @@ func (s VisionTemplateSet) TemplateNames() []string {
 	if s.Analytics {
 		names = append(names, "analytics.go.tmpl")
 	}
+	names = append(names, s.Workflows...)
+	names = append(names, s.Insights...)
 	return names
 }
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 472278b6..88725188 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -8,6 +8,33 @@ import (
 	"github.com/mvanhorn/cli-printing-press/internal/vision"
 )
 
+type DomainArchetype string
+
+const (
+	ArchetypeCommunication     DomainArchetype = "communication"
+	ArchetypeProjectMgmt       DomainArchetype = "project-management"
+	ArchetypePayments          DomainArchetype = "payments"
+	ArchetypeInfrastructure    DomainArchetype = "infrastructure"
+	ArchetypeContent           DomainArchetype = "content"
+	ArchetypeCRM               DomainArchetype = "crm"
+	ArchetypeDeveloperPlatform DomainArchetype = "developer-platform"
+	ArchetypeGeneric           DomainArchetype = "generic"
+)
+
+type DomainSignals struct {
+	Archetype        DomainArchetype
+	HasAssignees     bool
+	HasDueDates      bool
+	HasPriority      bool
+	HasThreading     bool
+	HasTransactions  bool
+	HasSubscriptions bool
+	HasMedia         bool
+	HasTeams         bool
+	HasLabels        bool
+	HasEstimates     bool
+}
+
 // APIProfile describes the shape of an API and what power-user features it warrants.
 type APIProfile struct {
 	HighVolume       bool
@@ -26,6 +53,8 @@ type APIProfile struct {
 
 	SyncableResources []string
 	SearchableFields  map[string][]string
+
+	Domain DomainSignals
 }
 
 func Profile(s *spec.APISpec) *APIProfile {
@@ -153,6 +182,8 @@ func Profile(s *spec.APISpec) *APIProfile {
 		p.SearchableFields[resource] = sortedKeys(fields)
 	}
 
+	p.Domain = detectDomainSignals(s)
+
 	return p
 }
 
@@ -173,6 +204,16 @@ func (p *APIProfile) ToVisionaryPlan(apiName string) *vision.VisionaryPlan {
 		},
 	}
 
+	plan.Domain = vision.DomainInfo{
+		Archetype:    string(p.Domain.Archetype),
+		HasAssignees: p.Domain.HasAssignees,
+		HasDueDates:  p.Domain.HasDueDates,
+		HasPriority:  p.Domain.HasPriority,
+		HasTeams:     p.Domain.HasTeams,
+		HasLabels:    p.Domain.HasLabels,
+		HasEstimates: p.Domain.HasEstimates,
+	}
+
 	plan.Architecture = append(plan.Architecture,
 		vision.ArchitectureDecision{
 			Area:               "persistence",
@@ -504,3 +545,110 @@ func sortedKeys[V any](m map[string]V) []string {
 	sort.Strings(keys)
 	return keys
 }
+
+func detectDomainSignals(s *spec.APISpec) DomainSignals {
+	if s == nil {
+		return DomainSignals{Archetype: ArchetypeGeneric}
+	}
+
+	scores := map[DomainArchetype]int{
+		ArchetypeCommunication:     0,
+		ArchetypeProjectMgmt:       0,
+		ArchetypePayments:          0,
+		ArchetypeInfrastructure:    0,
+		ArchetypeContent:           0,
+		ArchetypeCRM:               0,
+		ArchetypeDeveloperPlatform: 0,
+	}
+
+	resourceKeywords := map[DomainArchetype][]string{
+		ArchetypeCommunication:     {"message", "channel", "chat", "thread", "conversation", "dm", "reaction"},
+		ArchetypeProjectMgmt:       {"issue", "task", "ticket", "project", "sprint", "milestone", "board", "epic", "backlog"},
+		ArchetypePayments:          {"charge", "payment", "invoice", "subscription", "refund", "payout", "transaction", "balance", "transfer"},
+		ArchetypeInfrastructure:    {"server", "instance", "cluster", "deployment", "container", "node", "pod", "volume", "network"},
+		ArchetypeContent:           {"article", "post", "page", "blog", "content", "document", "media", "asset", "collection"},
+		ArchetypeCRM:               {"contact", "deal", "lead", "opportunity", "account", "pipeline", "company", "person"},
+		ArchetypeDeveloperPlatform: {"repository", "commit", "branch", "pull_request", "merge_request", "pipeline", "build", "release", "package"},
+	}
+
+	ds := DomainSignals{}
+
+	var walkResources func(name string, r spec.Resource)
+	walkResources = func(name string, r spec.Resource) {
+		nameLower := strings.ToLower(name)
+		for archetype, keywords := range resourceKeywords {
+			for _, kw := range keywords {
+				if strings.Contains(nameLower, kw) {
+					scores[archetype] += 2
+				}
+			}
+		}
+
+		for _, endpoint := range r.Endpoints {
+			scanFieldSignals(endpoint.Params, &ds)
+			scanFieldSignals(endpoint.Body, &ds)
+		}
+
+		for subName, sub := range r.SubResources {
+			walkResources(subName, sub)
+		}
+	}
+
+	for name, resource := range s.Resources {
+		walkResources(name, resource)
+	}
+
+	// Pick the archetype with the highest score
+	bestArchetype := ArchetypeGeneric
+	bestScore := 0
+	for archetype, score := range scores {
+		if score > bestScore {
+			bestScore = score
+			bestArchetype = archetype
+		}
+	}
+	ds.Archetype = bestArchetype
+
+	return ds
+}
+
+func scanFieldSignals(params []spec.Param, ds *DomainSignals) {
+	for _, param := range params {
+		name := strings.ToLower(param.Name)
+
+		if strings.Contains(name, "assignee") || name == "assignee_id" || name == "assigned_to" {
+			ds.HasAssignees = true
+		}
+		if strings.Contains(name, "priority") {
+			ds.HasPriority = true
+		}
+		if strings.Contains(name, "due_date") || strings.Contains(name, "due_at") || strings.Contains(name, "deadline") {
+			ds.HasDueDates = true
+		}
+		if strings.Contains(name, "team") || name == "team_id" {
+			ds.HasTeams = true
+		}
+		if strings.Contains(name, "label") || strings.Contains(name, "tag") {
+			ds.HasLabels = true
+		}
+		if strings.Contains(name, "estimate") || strings.Contains(name, "story_points") || strings.Contains(name, "points") {
+			ds.HasEstimates = true
+		}
+		if strings.Contains(name, "thread") || strings.Contains(name, "reply_to") || strings.Contains(name, "parent_id") {
+			ds.HasThreading = true
+		}
+		if strings.Contains(name, "amount") || strings.Contains(name, "currency") || strings.Contains(name, "price") {
+			ds.HasTransactions = true
+		}
+		if strings.Contains(name, "subscription") || strings.Contains(name, "recurring") || strings.Contains(name, "interval") {
+			ds.HasSubscriptions = true
+		}
+		if strings.Contains(name, "media") || strings.Contains(name, "attachment") || strings.Contains(name, "image") || strings.Contains(name, "file") {
+			ds.HasMedia = true
+		}
+
+		if len(param.Fields) > 0 {
+			scanFieldSignals(param.Fields, ds)
+		}
+	}
+}
diff --git a/internal/vision/insight.go b/internal/vision/insight.go
new file mode 100644
index 00000000..a7a3f9cf
--- /dev/null
+++ b/internal/vision/insight.go
@@ -0,0 +1,19 @@
+package vision
+
+type NonObviousInsight struct {
+	ObviousFrame   string
+	InsightFrame   string
+	SignalSentence string
+	Implications   []string
+}
+
+func (n NonObviousInsight) Formula(apiName string) string {
+	if n.InsightFrame == "" {
+		return ""
+	}
+	return apiName + " isn't just " + n.ObviousFrame + ". It's " + n.InsightFrame + ". " + n.SignalSentence
+}
+
+func (n NonObviousInsight) HasInsight() bool {
+	return n.InsightFrame != "" && len(n.Implications) > 0
+}
diff --git a/internal/vision/vision.go b/internal/vision/vision.go
index f4657f50..fea357d2 100644
--- a/internal/vision/vision.go
+++ b/internal/vision/vision.go
@@ -79,9 +79,21 @@ func (f *FeatureIdea) ComputeScore() int {
 		f.CompetitiveMoat
 }
 
+type DomainInfo struct {
+	Archetype    string
+	HasAssignees bool
+	HasDueDates  bool
+	HasPriority  bool
+	HasTeams     bool
+	HasLabels    bool
+	HasEstimates bool
+}
+
 type VisionaryPlan struct {
 	APIName       string
 	Identity      APIIdentity
+	Insight       NonObviousInsight
+	Domain        DomainInfo
 	UsagePatterns []UsagePattern
 	ToolLandscape []ToolClassification
 	Workflows     []Workflow
diff --git a/skills/printing-press/references/noi-examples.md b/skills/printing-press/references/noi-examples.md
new file mode 100644
index 00000000..8fb2297d
--- /dev/null
+++ b/skills/printing-press/references/noi-examples.md
@@ -0,0 +1,49 @@
+# Non-Obvious Insight (NOI) Formula
+
+## Formula
+
+`[API] isn't just [Obvious Frame]. It's [Insight Frame]. [Signal Sentence].`
+
+The NOI reframes what an API does from its surface-level description into a deeper
+insight about how power users actually leverage it. This drives CLI feature selection
+toward workflows that matter rather than CRUD wrappers.
+
+## Examples by Domain Archetype
+
+### Project Management
+
+1. **Linear**: Linear isn't just an issue tracker. It's a velocity sensor for engineering teams. Every sprint boundary reveals whether a team is accelerating or decelerating before managers notice.
+
+2. **Jira**: Jira isn't just a ticket system. It's an organizational memory of every decision and tradeoff. The comment threads on old tickets contain the rationale that onboarding docs never capture.
+
+3. **Asana**: Asana isn't just a task manager. It's a dependency graph that exposes bottlenecks. When three projects share the same blocked task, it reveals the single person everyone is waiting on.
+
+4. **GitHub Issues**: GitHub Issues isn't just a bug tracker. It's a public roadmap signal. The ratio of feature requests to bug reports tells you whether users want more or want better.
+
+### Communication
+
+5. **Slack**: Slack isn't just a messaging app. It's an attention allocator. The channels with the highest unread counts reveal the topics your organization cares about but hasn't staffed.
+
+6. **Discord**: Discord isn't just a chat platform. It's a community health monitor. Thread reply depth and reaction diversity predict whether a community is growing or hollowing out.
+
+7. **Twilio**: Twilio isn't just an SMS API. It's a delivery reliability surface. The gap between sent and delivered timestamps exposes carrier routing problems before customers complain.
+
+### Payments
+
+8. **Stripe**: Stripe isn't just a payment processor. It's a revenue pattern detector. Subscription churn clusters around specific billing cycle days, revealing pricing sensitivity windows.
+
+9. **Square**: Square isn't just a POS system. It's a foot traffic sensor. Transaction timestamps and amounts map customer behavior patterns that no survey captures.
+
+10. **Plaid**: Plaid isn't just a banking connector. It's a financial behavior classifier. Transaction categorization patterns reveal spending personalities that credit scores miss.
+
+### Infrastructure
+
+11. **AWS EC2**: EC2 isn't just a compute service. It's a capacity planning oracle. Instance type migration patterns across an org reveal which workloads are growing before the bills spike.
+
+12. **Cloudflare**: Cloudflare isn't just a CDN. It's a traffic intent classifier. The ratio of cached to uncached requests reveals whether your users are browsing or building.
+
+### Content
+
+13. **Notion**: Notion isn't just a wiki. It's a knowledge decay detector. Pages with high view counts but stale edit dates are the documentation traps that cause the most onboarding friction.
+
+14. **WordPress**: WordPress isn't just a CMS. It's a content performance lab. The correlation between publish timing, word count, and engagement reveals the format your audience actually wants.

← 31a8bed4 docs: comprehensive README rewrite for 8-phase pipeline  ·  back to Cli Printing Press  ·  feat(generator): add PM workflow and behavioral insight temp f55405b8 →