Construction schedule intelligence: from messy site reports to a costed recovery plan.
It turns free-text contractor daily progress reports into a structured database, computes schedule variance and a critical-path forecast, and runs an agent that finds the cheapest way to recover a slipping project.
| Result | |
|---|---|
| Input | 30 free-text daily reports in 4 formats (typed DPR, WhatsApp, semi-tabular, rambling narrative) |
| Extraction accuracy (live LLM) | 100% (135/135) line-to-activity match against a 135-line ground truth, 100% quantities, 100% manpower |
| Forecast | Finish 2027-03-11 vs contract 2027-01-14: 56 days late, INR 1.04 crore liquidated-damages exposure |
| Root cause found | A120 Pile caps & raft (own overrun 7 days) → delay chain A120 → A130 → … → A180 |
| Recovery plan | 55 days recovered for INR 22.1 lakh, 18 extra workers, within the INR 45 lakh budget and 35-worker pool |
| Plans evaluated | 2,744 lever combinations, each one a full CPM re-run |
| Runs offline | Yes. Every LLM step has a deterministic fallback, and the output labels which path ran |
Overview: headline metrics, root cause, and a baseline vs forecast Gantt with the critical path in red.

Recovery advisor: recommended plan, alternatives, cost vs days-saved frontier over every feasible plan, and
the float check.

More screenshots: Activities, Reports, About
Activities: % complete, performance factor, earned-schedule variance, float and critical flag per activity.

Reports: raw report beside the parsed table, parser path used, and the data-quality exceptions panel.

About: method, assumptions and known simplifications, shown in the app itself.

Site progress arrives as free text: a typed report one day, a WhatsApp message the next, a half-filled table after that. Each one names the same work differently ("RW panels near ramp", "bsmt wall shuttering", "retaining wall at grid 4-7"). The contract programme lives somewhere else, so nobody can answer three Monday-morning questions:
- How late are we, really?
- What caused it? (as opposed to what merely inherited the delay)
- What is the cheapest way to recover, and what will each extra rupee buy?
When the answer is a guess, crews get thrown at activities that have float, money is spent, and the finish date doesn't move.
| Stage | What happens | Module |
|---|---|---|
| 1. Parse | Each report is turned into a validated ParsedReport (pydantic v2) through a forced tool / function call. Aliases are mapped to baseline activities, units normalised (cum→m3, rmt→m), and bad data rejected into an exceptions log |
parser.py, llm.py, ingest.py |
| 2. Reconcile | Opening position plus reported quantities give % complete and a performance factor per activity. Earned-schedule variance in days, and a CPM forward/backward pass for forecast dates, total float and the critical path | schedule.py, variance.py |
| 3. Advise | A LangGraph agent diagnoses the root cause, builds a lever menu (extra crew, overtime, second shift, fast-track) and searches thousands of combinations through the CPM engine under budget, labour and crowding caps. It recommends the best plan with a cheaper and a maximum-recovery alternative, then writes a site-director memo | levers.py, agent.py |
| 4. Present | Streamlit dashboard with Plotly Gantt, variance table, report-vs-parse viewer and the recovery advisor | app.py |
flowchart LR
subgraph Inputs
B[baseline_schedule.csv<br/>18 activities]
R[30 daily reports<br/>4 free-text formats]
O[opening_progress.csv]
end
R --> P{parser}
P -- API key --> L[LLM chain<br/>Claude → Gemini → Groq<br/>forced tool call]
P -- no key / failure --> F[deterministic<br/>regex + keyword matcher]
L --> G[guards: date check,<br/>grounding, units,<br/>plausibility]
F --> G
G --> DB[(SQLite)]
B --> DB
O --> DB
DB --> CPM[CPM engine<br/>forward / backward pass]
CPM --> V[earned-schedule<br/>variance]
V --> DB
subgraph Agent [LangGraph recovery agent]
LS[load_state] --> DG[diagnose]
DG -- on time --> H[report_healthy]
DG -- slipping --> GO[generate_options] --> SI[simulate<br/>2,744 CPM re-runs] --> EV[evaluate] --> NA[narrate] --> PE[persist]
end
DB --> LS
PE --> DB
DB --> UI[Streamlit dashboard]
Every claim above is backed by output committed in docs/proof/. Excerpts are shown below; the full raw logs are in that folder.
tests/test_app.py::test_app_renders_without_exceptions PASSED
tests/test_levers.py::test_crowding_factor_monotonic_decreasing PASSED
tests/test_levers.py::test_add_crew_multiplier_formula PASSED
tests/test_levers.py::test_menu_respects_crowding_and_pool PASSED
tests/test_levers.py::test_overtime_multipliers_and_costs PASSED
tests/test_levers.py::test_plan_search_respects_budget_and_labour_caps PASSED
tests/test_levers.py::test_net_benefit_arithmetic PASSED
tests/test_levers.py::test_ld_saving_caps_at_contract_date PASSED
tests/test_levers.py::test_float_absorbs_non_critical_lever PASSED
tests/test_llm.py::test_provider_priority PASSED
tests/test_llm.py::test_gemini_structured_parse PASSED
tests/test_llm.py::test_rate_limit_is_retried PASSED
tests/test_llm.py::test_falls_through_to_groq_then_deterministic PASSED
tests/test_llm.py::test_json_in_text_is_accepted PASSED
tests/test_llm.py::test_placeholder_nulls_are_cleaned PASSED
tests/test_llm.py::test_wrong_llm_year_is_overridden_and_sentinels_dropped PASSED
tests/test_llm.py::test_hallucinated_quantity_is_not_grounded PASSED
tests/test_llm.py::test_text_call_for_narration PASSED
tests/test_llm.py::test_no_key_raises_llm_error_not_crash PASSED
tests/test_parser.py::test_fallback_line_match_accuracy PASSED
tests/test_parser.py::test_noise_lines_are_unmatched PASSED
tests/test_parser.py::test_report_dates_parse PASSED
tests/test_parser.py::test_unit_normalisation_and_mismatch_rejected PASSED
tests/test_parser.py::test_missing_key_never_raises PASSED
tests/test_schedule.py::test_forward_backward_pass_known_answers PASSED
tests/test_schedule.py::test_lever_on_floated_activity_saves_zero_days PASSED
tests/test_schedule.py::test_lever_on_critical_activity_saves_days PASSED
tests/test_schedule.py::test_in_progress_forecast_uses_observed_rate PASSED
tests/test_schedule.py::test_fast_track_negative_lag PASSED
tests/test_variance.py::test_half_done_halfway_is_on_schedule PASSED
tests/test_variance.py::test_quarter_done_halfway_is_behind PASSED
tests/test_variance.py::test_planned_pct_is_clamped PASSED
32 passed in 2.05s
Tests cover a hand-built 4-activity CPM network with known ES/EF/float answers, earned-schedule arithmetic,
crowding monotonicity, budget and labour caps in the plan search, net-benefit arithmetic, parser accuracy against
ground truth, unit-mismatch rejection, the LLM provider chain (against a mocked HTTP layer: retries, 429s,
fallthrough), and the LLM output guards. Full log: docs/proof/pytest.txt
make demo from a clean clone, fully offline (full log):
[ingest] LLM path: OFF - no LLM key set (ANTHROPIC_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY, GROQ_API_KEY), deterministic fallback in use
[ingest] 30 reports parsed via fallback; 135 lines, 3 unmatched, 3 exceptions; data quality 97.8%
[evaluate] activity match 135/135 = 100.0% | quantity 103/103 = 100.0% | manpower 132/132 = 100.0% | missed lines 0, extra lines 0
[variance] baseline finish 2027-01-14 forecast finish 2027-03-11 slip 56 days LD exposure INR 10,360,000
[variance] root cause: A120 Pile caps and raft foundation (own overrun 7 days)
[variance] delay chain: A120 -> A130 -> A140 -> A150 -> A160 -> A170 -> A180
[advise] LLM path: OFF - no LLM key set (ANTHROPIC_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY, GROQ_API_KEY), deterministic fallback in use
[advise] RECOMMENDED: saves 55 d, direct INR 2,106,000, risk INR 105,225, total INR 2,211,225, net INR 7,963,775, peak extra labour 18, finish 2027-01-15
[advise] CHEAPER ALTERNATIVE: saves 37 d, direct INR 910,800, risk INR 105,225, total INR 1,016,025, net INR 5,828,975, peak extra labour 12, finish 2027-02-02
[advise] MAXIMUM RECOVERY: saves 66 d, direct INR 3,383,625, risk INR 0, total INR 3,383,625, net INR 6,976,375, peak extra labour 18, finish 2027-01-04
[advise] recommended within caps: budget 2,106,000 <= 4,500,000: True; labour 18 <= 35: True
A230 second_shift TF 163 d days_saved = 0 cost INR 213,000 -> A230 has 163 days of total float, so accelerating it pulls its own finish in but the project finish does not move: float absorbs it. INR 213,000 for 0 days.
[advise] NARRATIVE (produced by: template (no LLM API key))
[summary] row counts: activities=18, opening_progress=6, daily_reports=30, progress_entries=135, variance_snapshots=18, recommendations=1
The same pipeline with GEMINI_API_KEY set (full log). Each extracted line is
scored against a ground-truth file written by the data generator, so accuracy is measured, not asserted:
[ingest] LLM path: Google Gemini (free tier) (gemini-flash-lite-latest); chain: Google Gemini (free tier) -> deterministic fallback
[ingest] DPR_2026-08-17.txt: llm 4 lines
[ingest] DPR_2026-08-18.txt: llm 3 lines
[ingest] DPR_2026-08-19.txt: llm 6 lines
[ingest] 30 reports parsed via llm; 144 lines, 5 unmatched, 19 exceptions; data quality 86.8%
[ingest] tokens in/out: 39148/10516
[evaluate] parser path(s): ['llm (gemini:gemini-flash-lite-latest)']
[evaluate] activity match 135/135 = 100.0% | quantity 103/103 = 100.0% | manpower 132/132 = 100.0% | missed lines 0, extra lines 7
[variance] baseline finish 2027-01-14 forecast finish 2027-03-11 slip 56 days LD exposure INR 10,360,000
[variance] delay chain: A120 -> A130 -> A140 -> A150 -> A160 -> A170 -> A180
[advise] LLM path: Google Gemini (free tier) (gemini-flash-lite-latest); chain: Google Gemini (free tier) -> deterministic fallback
[agent] narrate: gemini memo, 0 warnings
[advise] RECOMMENDED: saves 55 d, direct INR 2,106,000, risk INR 105,225, total INR 2,211,225, net INR 7,963,775, peak extra labour 18, finish 2027-01-15
[advise] NARRATIVE (produced by: llm - Google Gemini (free tier) (gemini-flash-lite-latest))
### What slipped and why
As of 2026-09-15, the project faces a forecast finish of 2027-03-11 against the baseline finish of 2027-01-14, representing a slip of 56 days and a li...
### What to do Monday morning
Two consecutive make demo runs produce byte-identical report files and identical variance numbers,
with no duplicated rows. Ingestion is idempotent on source_file. See docs/proof/determinism.txt.
The first live Gemini run surfaced three real failure modes. Each is now caught by a guard and covered by a regression test:
| What the model did | Effect if trusted | Guard |
|---|---|---|
Parsed WhatsApp dates like 22/08 as 2023-08-22 (8 of 30 reports) |
Activities appeared to have started 3 years early; float of −1,092 days | LLM date is cross-checked against the deterministic header parse and overridden on disagreement |
| Invented "100 pct" for the remark "Surveyor setting out for ramp area" and assigned it to a finished activity | Doubled that activity's quantity and skewed the portfolio performance factor | Grounding check: a quantity must literally appear in its source line. Progress on already-finished activities is rejected |
Returned -1, 9.99e+307, 0 "-" and "3 men" as quantities |
Garbage progress and false unit exceptions | Placeholder and sentinel cleaning; worker counts moved to manpower; implausible quantities rejected |
The same principle applies to the recovery memo: the LLM only receives the simulator's computed numbers, and any figure in the memo that is not in that data is flagged.
Performance factor. PF = observed daily output / (budget_qty / baseline_duration), clamped to [0.3, 1.5].
Not-started work uses the earned-work-weighted mean PF of started work, clamped to [0.7, 1.05], and the UI labels
it as assumed. Planned productivity is derived as budget_qty / (duration × crew), never hardcoded.
Earned-schedule variance (days).
planned_pct = clamp((data_date − baseline_start + 1) / duration, 0, 1)
variance_days = pct_complete × duration − planned_pct × duration (negative = behind)
CPM. Complete activities keep their actuals. In-progress activities finish at
data_date + ⌈remaining / (baseline_rate × PF × lever)⌉. Not-started activities follow their predecessors for
⌈duration / (PF × lever)⌉ days. The backward pass gives late dates and total float, and critical means float ≤ 0.
Level-of-effort activities are tracked but kept off the critical path.
Root cause. The earliest critical activity whose own duration overran by at least max(3 days, 15%). Inherited delay doesn't count, which separates the cause from its victims.
Recovery levers (C = planned crew, INR 1,150 per person-day):
| Lever | Output multiplier | Cost |
|---|---|---|
| add crew k ∈ {4, 8, 12} | (1 + k/C) × max(0.55, 1 − 0.30·k/C) (crowding loss) |
k × 1,150 / day |
| overtime 10 h / 12 h | 1.125 / 1.20 (Business Roundtable extended-overtime curve) |
0.375·C / 0.75·C × 1,150 / day |
| second shift | 1.85 |
C × 1,150 × 1.25 + 18,000 / day |
| fast-track | 30% overlap with predecessor (negative lag) | expected rework 15% of activity value |
Plan search. Take the cartesian product of levers over the top 4 critical activities (capped at 3,000 combinations). Each combination re-runs the full CPM and is scored with:
ld_saved = (base slip − plan slip, floored at 0) × INR 185,000/day # LDs stop at the contract date
net_benefit = ld_saved − (direct cost + expected rework cost)
Plans breaching the INR 45 lakh budget, the 35-worker pool (peak concurrent) or the 1.8× crowding ratio are dropped. The rest are ranked by net benefit, then by cost per day saved.
Key insight the tool surfaces: accelerating an activity that has float buys zero days. The advisor puts a second shift on the worst non-critical activities to prove it, e.g. INR 2.1 lakh on A230 moves the project finish by 0 days.
Python 3.11+ · pandas · SQLite (stdlib, no ORM) · pydantic v2 · LangGraph · Streamlit · Plotly · httpx · Anthropic SDK · pytest
git clone https://github.com/Tharun0127/SITE-PILOT-AI-.git && cd SITE-PILOT-AI-
make install # virtualenv + dependencies
make demo # seed -> ingest -> evaluate -> variance -> advise (works with no API key)
make app # dashboard at http://localhost:8501
make test # 32 testsOn Windows without GNU make, use mingw32-make demo or .venv\Scripts\python -m sitepilot.cli demo.
Optional LLM (free). Copy .env.example to .env and add a key. The first one available is used, and on
failure it falls through to the next:
| Priority | Provider | Key | Default model | Cost |
|---|---|---|---|---|
| 1 | Anthropic Claude | ANTHROPIC_API_KEY |
claude-sonnet-4-5 |
paid |
| 2 | Google Gemini | GEMINI_API_KEY (get one) |
gemini-flash-lite-latest |
free tier |
| 3 | Groq | GROQ_API_KEY (get one) |
llama-3.3-70b-versatile |
free tier |
| 4 | Deterministic | none | regex + keyword matcher, template memo | offline |
Free tiers are rate-limited, so requests are paced client-side and 429s are retried with backoff. Set
SITEPILOT_LLM_PROVIDER=gemini|groq|anthropic|none to pin one. Every cost-model value and the data date can be
overridden by environment variable.
CLI: python -m sitepilot.cli {init | seed | ingest [--use-llm | --no-llm] | evaluate | variance | advise | demo | app | clean}
sitepilot/
config.py settings, LLM provider chain, cost model, data date
db.py SQLite schema, idempotent upserts, loaders
models.py pydantic models for parsed reports
seed.py seeded generator: opening position, 30 reports, ground truth
llm.py provider chain (Claude / Gemini / Groq), retries, pacing, schema adaptation
parser.py LLM extraction + deterministic fallback, output cleaning, date check
ingest.py matching, unit normalisation, grounding and plausibility guards
schedule.py CPM forward/backward pass, performance factors
variance.py earned-schedule variance, root-cause diagnosis
levers.py recovery levers, plan search, float-absorption check
agent.py LangGraph recovery agent + memo
evaluate.py accuracy against ground truth
cli.py command line
app.py Streamlit dashboard
tests/ 32 tests
data/ baseline schedule + generated reports and ground truth
docs/ screenshots and proof logs
There is no external dataset. sitepilot/seed.py generates everything from a single seeded RNG:
- Hidden performance factors per activity: the ground truth the tool has to discover. They never appear in a report or in the database.
- Disruption calendar: flood, RMC plant breakdown, labour dispute, rain and a festival holiday.
- Four report formats with 4–5 aliases per activity, so exact string matching can't work.
- Hard rules: reports never state percent complete or schedule status. The tool has to derive them.
ground_truth.csvrecords the true activity, quantity and manpower for every report line. It is used only for evaluation and never read by ingestion.
- Calendar days, not a working calendar. The 6-day week and holidays only appear as lost output.
- Linear planned progress within each baseline window, and finish-to-start links only.
- Synthetic data. The deterministic parser reaches 100% on it, but its abbreviation lexicon was written alongside the generator, so treat that figure as a regression test. The live LLM accuracy above is the more honest number.
- No resource levelling. The labour pool is checked as peak concurrent extra workers.
Map a P6 XER (TASK, TASKPRED, TASKRSRC) or an MS Project export onto the activities table. That needs
SS/FF/SF relationships with lags and a working calendar added to the network model. Budget quantities come from the
BOQ or a cost-loaded schedule. Seed each activity's keywords from the WBS path and historical reports, and replace
the opening-progress CSV with the last statused update. Before trusting the forecasts, validate them against a
statused P6 update.