- Added REPO_INVENTORY.md with all repos, branches, remotes, and staging info - Added .gitignore - Synced all existing docs from local workspace - Centralized documentation hub for GrowQR team
15 KiB
Implementation plan — Brand Score: full PRD component model + breakdown
Date: 2026-03-27
Scope: PRD additions item (1) (from prd-additions.md)
Non-goals (explicit PRD exclusions for this phase): posting, executing actions on platforms, adding new platforms beyond LinkedIn-first.
Checklist (requirements coverage)
- Add remaining Brand Score components as first-class computed fields (not just a single audit-derived score):
- Content Consistency (20%)
- Audience Growth (15%)
- Engagement Rate (15%)
- Recruiter Visibility (15%)
- Content Quality (10%)
- Cross-Platform Consistency (5%) (schema-ready; compute from available platforms only)
- Store/persist component breakdown and PRD-required “objective, measurable signals” explanations
- Implement Brand Score trend line (history storage + API response)
- Implement v1 proxies + renormalization for LinkedIn-private analytics gaps
- Add metrics snapshot persistence (followers/connections over time) so Audience Growth + trendline are computable
1) Outcome + contract
Inputs
Per user_id and a computation window (rolling), compute with:
- LinkedIn profile snapshot: headline/about/experience/skills (as currently ingested)
- Post/activity list over a window (suggest 30 days): timestamps, text, and any available post metrics
- Metrics snapshots over time (if present/collectible): followers/connections, impressions, engagements, profile views, search appearances
- Persona context (if present): target persona/role signals used in content quality and recruiter visibility proxy
v1 data reality (current repo ingestion):
- Profile snapshot fields +
followers_count/connections_countare present. activityexists but is typically limited (date/title/url + sometimes likes/comments).- Impressions/reach, profile views, and search appearances are typically not available in LinkedIn-first v1; treat as proxy/N.A.
Outputs
brand_score_total(0–100)brand_score_components[]each with:key(stable enum)weight(PRD)score(0–100)weighted_points(score × weight)signals(structured numeric strings/values for auditability)explanation(human-readable, objective/measurable)insufficient_data(boolean)
brand_score_history[]trendline points (date, total score)
Error + missing data behavior
Pick one explicit policy so scores don’t jitter unpredictably:
- Recommended v1: weight renormalization.
- If component lacks minimum data, mark
insufficient_data=true. - Exclude it from total weighting and renormalize remaining weights to sum to 1.0.
- Still return the missing component in breakdown (so UI can display “N/A”).
- If component lacks minimum data, mark
v1 proxy rule: if a PRD metric is not collectible on LinkedIn (e.g., impressions, profile views, search appearances), compute a deterministic proxy where possible; otherwise mark the component insufficient_data=true and rely on renormalization.
Versioning
Persist a scoring version string (e.g. 2026-03-27.v1) with each snapshot to support future formula tweaks.
2) Component model (PRD weights + measurable signals)
Scores are 0–100. Each component must produce structured
signalsand a plain-language explanation referencing those signals.
2.1 Content Consistency — 20%
Goal: consistency of posting frequency + tone consistency over a rolling window.
Signals (v1):
posts_last_30ddays_between_posts_avgdays_between_posts_stddevweeks_meeting_target_cadence(e.g., >=2 posts/week or configured target)tone_label_distribution(optional v1) ortone_consistency_score
v1 note: cadence is computable from activity[].date if present.
Important v1 filter: Bright Data “activity” often includes interactions (e.g., “Liked by X”), not just authored posts. Use activity[].type (populated from Bright Data interaction when available) to exclude non-authored items from cadence calculations.
Tone is optional and should be insufficient_data=true unless post text is available.
Tone consistency (addendum — v1.1 implementation checklist)
We currently score cadence deterministically from activity dates; this addendum covers adding the optional “tone consistency” portion without destabilizing totals.
-
Extend ingestion/normalization so activity items include authored post text when available
- Prefer a stable field name in the normalized profile dict (e.g.
activity[].text). - Ensure non-authored items are filtered (continue using
activity[].type/interactionheuristics).
- Prefer a stable field name in the normalized profile dict (e.g.
-
Add tone classification (deterministic-first, AI optional)
- Add adapter method (or reuse existing AI adapter) to classify tone labels for a post: e.g.
{tone: "educational"|"opinion"|"personal"|"promo"|...} - Cache tone labels per post URL/id when possible to avoid re-scoring.
- Add adapter method (or reuse existing AI adapter) to classify tone labels for a post: e.g.
-
Compute tone consistency signals
tone_label_distribution(counts per label)tone_entropy(or similar) as the numeric signaltone_consistency_scoremapped 0–100- If fewer than N posts have text (suggest N=3), mark tone as
insufficient_data=true.
-
Merge cadence + tone into the existing
content_consistencyscore- Recommended split: cadence 70%, tone 30% (configurable)
- Keep overall component deterministic when AI is unavailable:
- If AI tone classification fails, fall back to
insufficient_data=truefor tone and score cadence only.
- If AI tone classification fails, fall back to
-
Update explanation string to include tone when scored
- Example: “Cadence variance is X days; tone consistency is Y/100 across N posts.”
-
Add tests
- Test cadence-only path remains unchanged.
- Test tone-scored path produces stable distribution + score.
Computation idea (v1 deterministic + optional AI):
- Frequency score derived from cadence stability:
- reward meeting minimum weekly target
- penalize high variance in gaps
- Tone consistency (optional): classify each post tone (adapter AI), compute entropy / variance; map to 0–100.
Explanation template:
- “You posted {posts_last_30d} times in 30 days. Cadence variance is {stddev} days; {weeks_meeting_target_cadence}/4 weeks meet target.”
2.2 Audience Growth — 15%
Goal: follower/connection growth rate over last 30 days.
Signals:
followers_start_30dfollowers_end_30dfollower_growth_absfollower_growth_rate
v1 requirement: this component needs historical follower counts. Implement via metrics snapshots (see §3.3).
Computation (v1):
growth_rate = (end - start) / max(start, 1)- Map to 0–100 using bands (configurable), e.g.:
- <=0% → 10
- 0–1% → 40
- 1–3% → 70
- 3–6% → 85
-
6% → 95
Explanation:
- “Followers changed from {start} → {end} (+{abs}, {rate:.1%}) in 30 days; target band is {target_band}.”
2.3 Engagement Rate — 15%
Goal: likes/comments/shares/saves as % of reach.
Signals:
posts_countedimpressions_total(orreach_totalif you have it)engagements_totalengagement_rate_weighted_avgengagement_rate_p50/p75(optional)
Computation (v1):
- Prefer weighted average:
sum(engagements)/sum(impressions)across posts with impressions. - Guardrails for low denom.
v1 LinkedIn-first proxy: if impressions/reach aren’t available, use a deterministic proxy:
engagements_total = sum(likes + comments)across recent activity items with those fieldsengagements_per_post_avg = engagements_total / max(posts_counted, 1)- Optional normalization:
engagements_per_post_per_1k_followersIf engagement signals are missing/too sparse, markinsufficient_data=trueand renormalize.
Important v1 filter: only count authored posts (exclude “liked/commented/shared by user” items based on activity[].type).
Explanation:
- “Avg engagement rate is {rate:.2%} across {posts_counted} posts; benchmark target band {target_band}.”
2.4 Recruiter Visibility — 15%
Goal: profile appearance rate in recruiter/talent searches.
Signals (best effort LinkedIn-first):
- Preferred if collectible:
search_appearances_7d,search_appearances_30dprofile_views_7d,profile_views_30d
- Proxy if not collectible:
keyword_coverage_score(0–100)missing_keywords_count
Computation (v1):
- If search appearances/profile views available: score by percentile bands.
- Else: compute proxy keyword coverage vs target role/persona keyword set.
v1 default: treat search appearances/profile views as unavailable and use keyword coverage proxy.
Explanation:
- “Search appearances (7d): {x}. Keyword coverage {coverage}/100; missing {n} high-signal recruiter keywords.”
2.5 Content Quality — 10%
Goal: AI-assessed relevance/originality/alignment to persona framework.
Signals:
ai_quality_score_avgai_relevance_avg(optional detail)ai_originality_avg(optional detail)ai_persona_alignment_avg(optional detail)low_quality_flags(e.g., repetitive template, too short)
Computation (v1):
- Use AI adapter to score posts (sample last N posts).
- Average to 0–100; apply small penalties for repeated/low-effort patterns.
v1 fallback: if post text isn’t collectible, score using profile text (headline + summary + recent roles) and reflect that in signals.
Explanation:
- “Quality score {score}/100 across {n} posts: relevance {r}/100, originality {o}/100, persona alignment {p}/100.”
2.6 Cross-Platform Consistency — 5% (schema-ready)
Goal: messaging/voice alignment across connected platforms.
Signals:
connected_platformsplatforms_scoredcross_platform_similarity(if >1 platform)
Computation (v1):
- If only LinkedIn connected: mark
insufficient_data=trueand exclude via renormalization. - If multiple: compare embedding similarity of bios/headlines + recent posts themes.
Explanation:
- “Only LinkedIn connected; cross-platform consistency isn’t scored yet.”
3) Persistence + history (trend line)
3.1 Storage model
Add a snapshot table to store history and component breakdown for auditability.
Recommended table: brand_score_snapshots
iduser_idplatform(string/enum)window_start,window_endscore_total(numeric)components_json(JSON/JSONB)scoring_version(string)created_at
Also store a “latest” summary on the user profile (fast reads):
latest_brand_score_totallatest_brand_score_components_jsonlatest_brand_score_calculated_at
3.2 Trendline
- Provide daily points (last N days).
- If multiple snapshots/day: use latest per day (simple, stable).
3.3 Metrics snapshots (Audience Growth input)
Add a small metrics history table so we can compute change-over-time deterministically.
Recommended table: social_metrics_snapshots
iduser_idplatform(string/enum; v1 =linkedin)followers_count(int nullable)connections_count(int nullable)captured_at(timestamp)
Capture policy (v1):
- Insert a snapshot after each successful LinkedIn sync.
- Optional dedupe: keep at most 1 snapshot/day per user+platform.
4) Engine design (code structure)
4.1 Orchestrator
In app/engine/brand_score_engine.py (or equivalent), implement:
compute_brand_score(user_id, platform, window_days=30) -> BrandScoreResultcompute_and_persist_brand_score(...) -> BrandScoreSnapshot
4.2 Component functions
Create pure functions (easy to test):
score_content_consistency(data) -> ComponentResultscore_audience_growth(data) -> ComponentResultscore_engagement_rate(data) -> ComponentResultscore_recruiter_visibility(data) -> ComponentResultscore_content_quality(data) -> ComponentResultscore_cross_platform_consistency(data) -> ComponentResult
4.3 Config + thresholds
Centralize mapping bands and targets so they’re easy to tune:
- engagement target bands
- growth target bands
- cadence target
- minimum sample sizes (e.g., >=3 posts for some components)
5) API changes
5.1 Endpoints (or dashboard response augmentation)
Add (or extend existing dashboard routes):
GET /brand-score/latest
- Returns: total + components breakdown +
scoring_version+window.
GET /brand-score/trend?days=30
- Returns: array of
{date, score_total}(optionally include component totals later).
5.2 Response schemas
Add Pydantic models:
BrandScoreComponentBreakdownBrandScoreSnapshotResponseBrandScoreTrendResponse
6) Triggering computations
Compute snapshot when inputs change.
Recommended triggers:
- After each LinkedIn sync job completes
- Optionally: nightly job (backup) to ensure fresh trendline
Implementation:
- Hook into job dispatcher / sync pipeline to call
compute_and_persist_brand_score(user_id, "linkedin").
v1 addition: after LinkedIn sync, also write a social_metrics_snapshots row (followers/connections) so Audience Growth works.
7) Tests (minimum set)
Add unit tests in tests/ to stabilize the scoring math:
test_brand_score_components_happy_path
- Fake dataset with posts + metrics
- Assert all 6 component keys present
- Assert weights consistent with PRD
- Assert total score within expected range and deterministic
test_brand_score_missing_data_renormalization
- Omit recruiter visibility metrics & cross-platform
- Assert
insufficient_dataflagged - Assert total calculation uses renormalized weights
test_brand_score_snapshot_persistence
- Compute-and-persist writes a snapshot and updates “latest” summary
test_metrics_snapshots_enable_audience_growth
- Insert two snapshots in-window
- Assert the Audience Growth component derives start/end from snapshots
8) Execution steps (recommended order)
- Add DB migration(s):
brand_score_snapshots+ latest fields on user summary 1b) Add DB migration:social_metrics_snapshots - Implement/extend scoring engine with component spec + versioning
- Add repository methods for snapshot insert + trend queries
- Add API endpoints/schemas for latest + trendline
- Wire compute trigger after sync
- Add tests and make them green
9) Notes / open questions (non-blocking)
- Recruiter visibility and search appearances might be hard to collect reliably without LinkedIn API access; v1 can use a proxy keyword coverage score.
- Engagement rate impressions/reach may be unavailable on LinkedIn-first v1; use engagement proxies or mark insufficient data + renormalize.
- Cross-platform consistency should be marked “insufficient data” unless multiple platforms are connected.
- If you already have brand score summary fields on
social_users, reuse them; otherwise add them.