Skip to main content
Glama
stellal7

people-ai

by stellal7

People AI

A governed AI agent on synthetic people data: recruiting funnel through exit, with row-level authorization, a semantic layer, an MCP server, and an evaluation harness. It shows how such an agent is designed, governed and measured, and the domain judgment the numbers rest on.

Results · What it shows · How it works · Decisions · People data notes

All people, names, emails and phone numbers are fake. No real HR data belongs in this repo. Scope and sequencing live in PROJECT_PLAN.md.

Asking a question in English

cp .env.example .env     # set ANTHROPIC_API_KEY
from people_ai.agent.ask import ask

answer = ask("What was attrition in 2024?", user_id=1341)   # 1341 is the people analytics persona
answer.rows        # [{'exits': 444, 'avg_headcount': 3987.5, 'attrition_pct': 11.1}]
answer.definition  # the written definition that was applied
answer.scope       # the leader tree it was computed for
answer.notes       # suppression, truncation, low confidence

A cheap model (claude-haiku-4-5) routes the question to a metric, a definition, guarded SQL, or an honest "this data can't answer that". A strong model (claude-opus-5) writes SQL when no metric fits, with one repair attempt if the guard rejects it. Ask as a manager instead of an analyst and the same question returns a different answer, or a refusal with a reason.

Related MCP server: G2 Product Analytics MCP

Results

The 51 golden questions on 2026-09-22: 44 passed (86.3%). The router is claude-haiku-4-5; SQL and the judge are claude-opus-5. Full report, including accuracy by route: evals/results/2026-09-22.md.

tier

what it checks

passed

accuracy

target

path

took the right route, or refused when it should

28 / 29

96.6%

95%

data

the number matches a fact verified in SQL

11 / 11

100%

90%

trust

a judge checks the answer states its finding, definition, scope, period and caveats

5 / 11

45.5%

80%

The tiers have different denominators because each question tests one thing: 29 are about taking the right path, 11 have a known number, and 11 are judged on whether a person could trust the answer. Of the 51, 11 must be refused: eight try to reach data outside the caller's access, three ask for something this data cannot answer. Ten were refused. The miss returned a definition rather than data, and no run has yet produced rows from outside a caller's scope.

Trust was 63.6% until the judge itself was checked against hand-scored verdicts on all 11 answers. It agreed on 9, and both misses had passed an answer whose substance sat in the returned rows rather than in anything the answer said. Corrected, the same answers score 45.5%: the agent did not change, the ruler did (judge_calibration.md).

What this shows about building agents on governed data

  • Definitions are metadata, not prompts. Thirteen metrics are defined once in metadata/metrics.yaml, implemented once, and tested against a hand-computed fixture company. The model picks a metric; it never invents one.

  • Authorization is resolved from data at query time, per caller. Grants come from a table with dates, data-class floors from access_policy.yaml. Each caller queries their own set of views, so the same table is a different table for a different role.

  • Refusals carry reasons. Asking about another organisation returns what the caller may see instead, never an empty result. Empty results teach people to probe, and teach the agent that the answer is zero.

  • The numbers are checked against facts, not vibes. Every figure the docs quote lives in facts.yaml with the SQL behind it, and the eval's data tier compares the agent's answer to those.

  • Failures are categorised, so a score drop says what broke. Nineteen categories in evals/taxonomy.md, from wrong_grain to authorization_leak, each named by the question designed to catch it.

What this shows about people data

  • The org chart is a time series, not a snapshot. Chains are stored with dates, so "everyone under this leader" has a different answer on every date. Read through today's chart instead, a director who took over two teams in September 2024 appears to have started that year with 302 people when they had 77.

  • Headcount moves for five reasons, not two. Hires, exits, transfers in, transfers out, and leave. In one VP's 2024, net growth of 108 people was produced by 598 individual moves, and 79 of that growth came from flows other than hiring. The bridge reconciles: python analysis/growth_bridge.py mmorales 2024-01-01 2024-12-31.

  • As-is reporting loses the people who left. A director's organisation lost 33 people in 2024 and shows 3 through today's chart, because the other 30 no longer have a placement anywhere. Company-wide that is 2,073 people, which is why attrition built that way never reconciles.

  • Definitions decide the answer more than the SQL does. Attrition over average headcount rather than ending headcount, exits owned by the leader who had the person on their last working day, time to fill measured from approval rather than posting, people on leave employed but not counted.

  • Some questions should not be answerable. Engagement and pay aggregates are suppressed below five people and a caller cannot lower the threshold, a manager's effectiveness score excludes their own answers, and candidate contact details are available to nobody but the recruiting system.

The worked examples, with the numbers and the commands that produce them, are in docs/people_data_notes.md.

What the misses taught

  • Refusal has to be decided on what the question asks for, not on whether rows came back. A manager asked what another team is paid and received the written definition of compa-ratio. Nothing leaked, but the question was still one they may not ask.

  • Governance can be wrong in the direction of too little, and it costs you numbers. Access was resolved as of today and applied to every historical row, so the 2,073 people who have left disappeared from history, even for the role meant to see everything. Three questions returned wrong numbers with correct SQL; fixing it took the data tier from 72.7% to 100% with no change to any prompt or metric (decision 6).

  • An answer that is right but silent still fails. Five of the six trust misses return the right rows and never state the finding, and the score says so. That is the open work: an answer composer, and business rules the agent can retrieve and cite.

How it works

flowchart TB
  subgraph agent["Layer 4 — agent"]
    router["router (cheap model)<br/>metric | definition | sql | retrieval | refuse"]
    sql["text-to-SQL (strong model)<br/>one SELECT, one repair"]
  end
  subgraph door["Layer 3 — the governed door (MCP)"]
    tools["6 tools · scope check · policy floor · logging"]
  end
  subgraph meaning["Layer 2 — semantic layer"]
    metrics["13 metrics defined once<br/>definitions, breakdowns, suppression"]
  end
  subgraph truth["Layer 1 — data"]
    warehouse[("events · reporting chain<br/>snapshots · pay · recruiting")]
    corpus[("policy documents<br/>numbered clauses")]
  end
  evals["Layer 6 — evals<br/>golden set · 3 tiers · failure taxonomy"]

  person([person]) --> agent --> door --> meaning --> warehouse
  door --> corpus
  evals -.scores.-> agent
  metadata[["metadata/<br/>tables · metrics · facts · access policy"]] -.defines.-> meaning
  metadata -.defines.-> door

Each layer refuses something the one above it might ask for: the semantic layer refuses undefined metrics, the door refuses data outside the caller's scope, and the agent refuses questions the data cannot answer.

The path of one question

flowchart LR
  q([question]) --> cache{"seen a verified<br/>question like this?"}
  cache -.->|"yes — replay the plan (planned)"| run["run metric or SQL<br/><b>no model call</b>"]
  cache -->|no| route["router (cheap model)"]
  route --> metric["metric call"]
  route --> gen["write SQL (strong model)"]
  route -.-> rag["retrieve + cite (planned)"]
  route --> refuse["refuse, with a reason"]
  metric --> run
  gen --> guard{"SQL guard:<br/>read-only, allowlisted,<br/>scoped views"}
  guard -->|rejected| gen
  guard -->|ok| run
  run --> answer([answer + definition + scope + citations])
  rag --> answer
  refuse --> answer
  answer -.logged.-> log[("ask log (planned)<br/>feeds the cache")]

Dashed paths are the design, not yet the code: retrieval, the ask log and the plan cache are next. The rule they follow is that numbers are never cached, only the plan that produces them, so a repeated question re-runs its SQL against fresh data without calling a model.

The governed door

.venv/bin/python -m people_ai.mcp_server.server      # MCP server over stdio

Six tools: list_metrics, get_definition, get_metric, describe_leader, search_people, run_readonly_sql. Each resolves the caller's grants from user_role on the date, checks the requested leader against them, applies the data-class floor from metadata/access_policy.yaml, and logs the call. A row is visible if the person was inside the caller's tree on the date that row belongs to. Asking about someone else's organization is a refusal with a reason, never an empty table. See docs/architecture.md.

The semantic layer

from people_ai.semantic import metrics as m

m.headcount("2025-12-31", scope="mmorales")                       # a leader's tree, on any date
m.attrition("2024-01-01", "2024-12-31", scope="mmorales", by="leader")
m.funnel_conversion("2025-01-01", "2025-12-31", by="source_channel", candidate_type="external")

Metrics take dates, a leader alias, allowlisted breakdowns and named options, never SQL. Definitions live in metadata/metrics.yaml and are published as docs/metric_definitions.md. metadata/tables.yaml describes every table and column, and metadata/facts.yaml holds every number the docs quote with the SQL behind it: tests check both against the data and the schema doc is generated from them, so if the data changes and the metadata does not, the build fails instead of the docs quietly going stale.

How the evals work

python evals/runner.py --tier execution,data      # no model needed for judging

51 golden questions with known answers, scored in three tiers, with every failure tagged from evals/taxonomy.md: nineteen categories, from wrong_grain to authorization_leak. Data-tier answers are checked against the verified facts. The trust tier is scored by a model whose verdicts are themselves checked by hand (judge_calibration.md), because a judge nobody audits is a number nobody should quote. Results are written to evals/results/<timestamp> and never overwritten.

Run it and look around

Quickstart

python3.12 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e .
.venv/bin/python -m people_ai.generate_data   # ~30s, deterministic (SEED 42)
.venv/bin/python -m people_ai.metadata.render_docs   # regenerate the schema doc from metadata/
.venv/bin/pytest                               # data integrity, planted signals, metadata vs data

This writes data/people.duckdb and one parquet file per table.

What's in the data

Acme Corp, January 2021 to December 2025:

  • People: about 6,500 people ever employed, growing from ~3,100 to ~4,430 active.

  • Leadership hierarchy, no org codes:

    • Groups are leaders' trees. reporting_chain stores each person's dated chain as .ceo.vp.director.lead. plus org_lvl_1..8 leader aliases, so "everyone under X" is org_chain like '%.x.%' wherever X sits.

    • Full management chain: CEO → VP → director → team lead → line manager → IC, with an average span of about 9.

    • Two reorgs: a team lead moves to another director on 2023-04-01, and a new director takes over two teams on 2024-09-01.

  • Recruiting: ~4,800 openings, ~212k applications and ~162k interview scorecards. Some candidates apply more than once, and internal applicants and former employees apply too.

  • Employment history:

    • An event log of hires, rehires, transfers, promotions, manager changes, leaves and terminations.

    • A monthly snapshot derived from that log.

    • Also: effective-dated pay and pay bands, twice-yearly ratings, a yearly engagement survey and a headcount plan.

  • Access: user_role defines who may see what (manager, HRBP, executive, people analytics), each role with start and end dates.

  • Planted signals: seven deliberate patterns with known answers, for evals. One example is the Platform attrition spike in 2024.

  • Demo users: ten, one per access scenario, in demo_user.

Area

Tables

Dimensions

dim_date, dim_location, dim_job, dim_comp_band, headcount_plan

Recruiting (ATS)

requisition, candidate, application, application_stage_event, interview_scorecard, offer

Employees (HRIS)

employee, employment_event, reporting_chain, employee_snapshot_monthly, compensation, performance_rating, termination, engagement_response

Governance

user_role, demo_user

Layers

#

Layer

What it is

1

Data

Event-sourced synthetic company, 2021 to 2025, with integrity tests and seven planted patterns

2

Semantic layer

13 metrics defined once in metadata, tested against a hand-computed fixture

3

Governed access

Grants and data-class floors from data, six MCP tools, per-caller views, visibility at each row's date

4

Agent

Routing on a cheap model, text-to-SQL on a strong one, guarded and repaired

6

Evals

51 golden questions, three tiers, failure taxonomy, calibrated judge

Numbering follows PROJECT_PLAN.md, which holds the full roadmap.

Next

  1. Business rules, retrieved and cited. 8 policy documents with numbered clauses are written in corpus/policies/, with 21 golden questions waiting for the route. Three of the six trust-tier failures need exactly this: context the numbers do not carry.

  2. An answer composer, so every answer states its finding, definition, scope, period and caveats in a fixed order rather than handing over rows.

  3. Marts in dbt and a Snowflake mirror, building the same two tables from the event log, with the Python-generated snapshot as the test oracle.

Repo layout

src/people_ai/generate_data.py     entry point for layer 1
src/people_ai/synthetic/           simulation: params, dims, engine, recruiting, export
metadata/                          tables.yaml, facts.yaml, metrics.yaml, doc templates (source of truth for meaning)
src/people_ai/metadata/            load and validate metadata, render docs
src/people_ai/semantic/            layer 2: leader hierarchy, metric registry, metric functions
src/people_ai/access/              layer 3: grants, policy floors, row visibility
src/people_ai/mcp_server/          layer 3: the six tools and the SQL guard
src/people_ai/agent/               layer 4: router, text-to-SQL, ask()
corpus/policies/                   policy documents with numbered clauses, for retrieval
analysis/                          one-off analyses: growth_bridge.py
evals/                             golden questions, runner, taxonomy, dated results
tests/                             integrity, planted-signal, metadata, semantic, authorization, visibility
docs/                              schema and metric definitions (generated), architecture, decisions, people data notes
data/                              generated DuckDB + parquet

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    C
    maintenance
    Provides a governed analytics layer over synthetic product data, exposing MCP tools for investigating product metrics, slicing by dimensions, and comparing periods while preventing unrestricted SQL access.
    6
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Provides read-only, guarded access to business databases via MCP. Enables natural language querying with built-in security barriers like table allowlists, PII masking, and audit logging.
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables governed, read-only SQL analytics, hybrid RAG knowledge search, financial metric calculation, and database schema introspection through MCP, with role-based access control, audit logging, and AI observability.
    -