Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 307 additions & 5 deletions README.md

Large diffs are not rendered by default.

28 changes: 20 additions & 8 deletions back-end/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,41 +11,53 @@ front-end (Vite) -> this API :3500 -> MongoDB Atlas

**This layer stores; the model service scores.** Reads never reach the model service —
everything the board shows comes out of Mongo, which is what makes the history durable and
a restart harmless. Only `/api/ward/seed`, `/api/ward/tick` and `/api/patient/:id/explain`
cross the boundary.
a restart harmless. Only `/api/ward/seed`, `/api/ward/tick`, `/api/ward/warmup` and
`/api/patient/:id/explain` cross the boundary.

Mongo is not a cache. Each stored reading carries the band the hysteresis machine
*published* at the time, which is not a function of that reading's score and cannot be
recomputed later.

## Running it

First-time setup — the sibling `bki/` checkout, the venv, the fitted artifacts and the two
package managers — is in [`../README.md`](../README.md). A clone of this repository alone
cannot run the demo.

```powershell
# 1 the model service, from pulsemind_demo/ (needs the GPU)
$env:PYTHONPATH="..\bki"
..\.venv\Scripts\python.exe -m uvicorn app:app --app-dir back-end/pythonService

# 2 this API, from back-end/ (needs .env -- copy .env.example)
node server.js
# 2 this API, from ANY directory (needs back-end/.env -- copy .env.example)
node back-end/server.js

# 3 the dashboard, from front-end/
pnpm dev
```

⚠️ **No `PYTHONPATH` is needed** — this block used to set it. `pipeline.core` resolves
through the editable install of `pulsemind_bki`, verified with the variable unset.

⚠️ **`server.js` resolves `.env` against its own directory**, not the working directory, so
the cwd no longer matters. Before that fix, launching it from anywhere but `back-end/`
loaded no `.env` at all and mongoose rejected an undefined `MONGODB_URI` — an error that
reads like a malformed URI and is actually a missing file.

The board is empty until `POST /api/ward/seed`.

## Endpoints

| Method | Path | |
|---|---|---|
| GET | `/api/ward` | every bed's latest assessment |
| POST | `/api/ward/seed` | build the ward, backfill 24 hours of scored history |
| POST | `/api/ward/tick` | advance every bed by one reading |
| POST | `/api/ward/seed` | build the ward and backfill `backfill_ticks` hourly readings (default 24) |
| POST | `/api/ward/tick` | advance every bed by one reading, an hour on the ward's clock |
| POST | `/api/ward/warmup` | load the 7B ahead of the first explanation (~40 s, stores nothing) |
| GET | `/api/patient/:id` | one patient's current assessment |
| GET | `/api/patient/:id/history` | recent assessments, oldest first |
| GET | `/api/patient/:id/context` | borrowed demographics and comorbidities |
| GET | `/api/patient/:id/parameter/:name` | one parameter's charting history |
| POST | `/api/patient/:id/explain` | generate the explanation (slow; `use_llm: false` for the template) |
| POST | `/api/patient/:id/explain` | generate the explanation (slow; `assessed_at` names the reading, `use_llm: false` picks the template) |
| POST | `/api/patient/:id/device` | switch an input source off or on |
| POST | `/api/prompt/:id/review` | record a clinician's disposition |

Expand Down
24 changes: 23 additions & 1 deletion back-end/config/modelService.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const axios = require('axios');
const { requestId } = require('../middleware/requestContext');
const { requestId, timing, timingFrom } = require('../middleware/requestContext');

/** The FastAPI model service. Two timeouts because the endpoints differ by three
* orders of magnitude: ~66 ms to score, 18-23 s to explain on a local 7B. */
Expand All @@ -22,8 +22,30 @@ for (const client of [scoring, explaining]) {
client.interceptors.request.use((config) => {
const id = requestId();
if (id) config.headers['X-Request-Id'] = id;
config.startedAt = process.hrtime.bigint();
return config;
});

// The hop itself, and the model service's own spans carried up unaltered.
//
// `upstream` MINUS the forwarded stage durations is the transport cost --
// serialisation, the loopback socket, and FastAPI's own routing. Reporting it
// as a separate span is what lets a reader see that a 26 s explanation was 26 s
// of generation and not 26 s of anything this tier did.
const record = (response) => {
const startedAt = response?.config?.startedAt;
if (startedAt) {
timing('upstream', startedAt, process.hrtime.bigint());
}
timingFrom(response?.headers?.['server-timing']);
};

client.interceptors.response.use(
(response) => { record(response); return response; },
// A refusal is still a measurement, and the slow failures are the ones worth
// seeing: a 240 s explanation timeout and an instant 503 both arrive here.
(error) => { record(error?.response ?? { config: error?.config }); throw error; },
);
}

module.exports = { BASE_URL, scoring, explaining };
54 changes: 54 additions & 0 deletions back-end/config/queryTiming.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const { timing } = require('../middleware/requestContext');

/**
* Time every database round trip and report it as a `mongo` span.
*
* A schema plugin rather than 24 wrapped call sites in the controller, and
* applied EXPLICITLY in each model rather than through `mongoose.plugin()`.
* The global form only reaches schemas compiled after it runs, so it depends on
* `require` order in `server.js` -- and when that order changes the plugin does
* not fail, it silently stops timing. A missing measurement that reports as a
* clean run is worse than no measurement at all. Applied per model, a model
* added without it is a visible gap in one greppable place.
*
* ⚠️ THIS DEPENDS ON AsyncLocalStorage SURVIVING THE DRIVER'S ASYNC BOUNDARY.
* `timing()` resolves the current request out of the store; if the context were
* lost between issuing a query and its callback, it would find nothing and do
* nothing -- quietly. `checks/check_node.py` asserts a `mongo` span is actually
* present on a real response for exactly that reason: this is not a mechanism
* that can be trusted because it did not throw.
*/

// Named explicitly rather than by regex. Mongoose applies query, aggregate,
// document and model middleware from different registries, and a regex that
// looks like it covers all four covers whichever ones it happens to match.
const QUERY_OPS = [
'find', 'findOne', 'findOneAndUpdate', 'findOneAndDelete', 'findOneAndReplace',
'updateOne', 'updateMany', 'replaceOne',
'deleteOne', 'deleteMany', 'countDocuments', 'estimatedDocumentCount', 'distinct',
];

const started = function () { this._pmStartedAt = process.hrtime.bigint(); };

const finished = function () {
if (!this._pmStartedAt) return;
// The interval, not its length: `GET /ward` issues eight of these at once,
// and eight overlapping durations added together came to more than the whole
// request. The middleware unions them instead.
timing('mongo', this._pmStartedAt, process.hrtime.bigint());
this._pmStartedAt = undefined;
};

module.exports = function queryTiming(schema) {
schema.pre(QUERY_OPS, started);
schema.post(QUERY_OPS, finished);

// Document and aggregate middleware live in their own registries; `save`
// and `insertMany` never match a query hook however it is written.
schema.pre('save', started);
schema.post('save', finished);
schema.pre('insertMany', started);
schema.post('insertMany', finished);
schema.pre('aggregate', started);
schema.post('aggregate', finished);
};
Loading