feat: ж/д слой + cant_swim в скоринг + профили вне модели
Определение специфических рекомендаций (матрица профилей §8):
1. Ж/д слой (закрыт мёртвый railway ×2.5 у РАС):
- /api/v1/water/{case_id} отдаёт railway=rail как LineString
(без service/industrial/military веток), кэш общий v2;
- railway_warning «перекрыть/проверить немедленно» по профилям;
- SearchMap: Polyline слой ж/д (тёмно-красный), счётчики 💧/🚂.
2. cant_swim → профиль не_умеет_плавать (water ×3.0, без изменения
радиуса, critical_warning «обследовать водоёмы НЕМЕДЛЕННО»):
- раньше чекбокс влиял только на текст, в скоринге был пробел;
- derive в analyze._derive_profiles — работает и для closed_cases.
3. unmodeled_profiles: ДЦП/слабое зрение/слух — честная пометка
«вне поведенческой модели» с пояснением (vector_tasks B12:
профили без аналога не выдавать за учтённые); блок на фронте
в карточке здоровья.
Площадь воды: сферический эксцесс, проверен на квадрате 53° (744017 м²
vs 743272 точного). Тесты: 202 passed (новый test_cant_swim_profile).
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
from annotated_doc import Doc
|
||||
from pydantic import AfterValidator, BaseModel, Field, model_validator
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
# Canonical SSE event schema matching the OpenAPI 3.2 spec
|
||||
# (Section 4.14.4 "Special Considerations for Server-Sent Events")
|
||||
_SSE_EVENT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class EventSourceResponse(StreamingResponse):
|
||||
"""Streaming response with `text/event-stream` media type.
|
||||
|
||||
Use as `response_class=EventSourceResponse` on a *path operation* that uses `yield`
|
||||
to enable Server Sent Events (SSE) responses.
|
||||
|
||||
Works with **any HTTP method** (`GET`, `POST`, etc.), which makes it compatible
|
||||
with protocols like MCP that stream SSE over `POST`.
|
||||
|
||||
The actual encoding logic lives in the FastAPI routing layer. This class
|
||||
serves mainly as a marker and sets the correct `Content-Type`.
|
||||
"""
|
||||
|
||||
media_type = "text/event-stream"
|
||||
|
||||
|
||||
def _check_single_line(v: str | None, field_name: str) -> str | None:
|
||||
if v is not None and ("\r" in v or "\n" in v):
|
||||
raise ValueError(f"SSE '{field_name}' must be a single line")
|
||||
return v
|
||||
|
||||
|
||||
def _check_event_single_line(v: str | None) -> str | None:
|
||||
return _check_single_line(v, "event")
|
||||
|
||||
|
||||
def _check_id_valid(v: str | None) -> str | None:
|
||||
if v is not None and "\0" in v:
|
||||
raise ValueError("SSE 'id' must not contain null characters")
|
||||
return _check_single_line(v, "id")
|
||||
|
||||
|
||||
class ServerSentEvent(BaseModel):
|
||||
"""Represents a single Server-Sent Event.
|
||||
|
||||
When `yield`ed from a *path operation function* that uses
|
||||
`response_class=EventSourceResponse`, each `ServerSentEvent` is encoded
|
||||
into the [SSE wire format](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream)
|
||||
(`text/event-stream`).
|
||||
|
||||
If you yield a plain object (dict, Pydantic model, etc.) instead, it is
|
||||
automatically JSON-encoded and sent as the `data:` field.
|
||||
|
||||
All `data` values **including plain strings** are JSON-serialized.
|
||||
|
||||
For example, `data="hello"` produces `data: "hello"` on the wire (with
|
||||
quotes).
|
||||
"""
|
||||
|
||||
data: Annotated[
|
||||
Any,
|
||||
Doc(
|
||||
"""
|
||||
The event payload.
|
||||
|
||||
Can be any JSON-serializable value: a Pydantic model, dict, list,
|
||||
string, number, etc. It is **always** serialized to JSON: strings
|
||||
are quoted (`"hello"` becomes `data: "hello"` on the wire).
|
||||
|
||||
Mutually exclusive with `raw_data`.
|
||||
"""
|
||||
),
|
||||
] = None
|
||||
raw_data: Annotated[
|
||||
str | None,
|
||||
Doc(
|
||||
"""
|
||||
Raw string to send as the `data:` field **without** JSON encoding.
|
||||
|
||||
Use this when you need to send pre-formatted text, HTML fragments,
|
||||
CSV lines, or any non-JSON payload. The string is placed directly
|
||||
into the `data:` field as-is.
|
||||
|
||||
Mutually exclusive with `data`.
|
||||
"""
|
||||
),
|
||||
] = None
|
||||
event: Annotated[
|
||||
str | None,
|
||||
AfterValidator(_check_event_single_line),
|
||||
Doc(
|
||||
"""
|
||||
Optional event type name.
|
||||
|
||||
Maps to `addEventListener(event, ...)` on the browser. When omitted,
|
||||
the browser dispatches on the generic `message` event. Must be a
|
||||
single line.
|
||||
"""
|
||||
),
|
||||
] = None
|
||||
id: Annotated[
|
||||
str | None,
|
||||
AfterValidator(_check_id_valid),
|
||||
Doc(
|
||||
"""
|
||||
Optional event ID.
|
||||
|
||||
The browser sends this value back as the `Last-Event-ID` header on
|
||||
automatic reconnection. **Must be a single line** and must not contain
|
||||
null (`\\0`) characters.
|
||||
"""
|
||||
),
|
||||
] = None
|
||||
retry: Annotated[
|
||||
int | None,
|
||||
Field(ge=0),
|
||||
Doc(
|
||||
"""
|
||||
Optional reconnection time in **milliseconds**.
|
||||
|
||||
Tells the browser how long to wait before reconnecting after the
|
||||
connection is lost. Must be a non-negative integer.
|
||||
"""
|
||||
),
|
||||
] = None
|
||||
comment: Annotated[
|
||||
str | None,
|
||||
Doc(
|
||||
"""
|
||||
Optional comment line(s).
|
||||
|
||||
Comment lines start with `:` in the SSE wire format and are ignored by
|
||||
`EventSource` clients. Useful for keep-alive pings to prevent
|
||||
proxy/load-balancer timeouts.
|
||||
"""
|
||||
),
|
||||
] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_data_exclusive(self) -> "ServerSentEvent":
|
||||
if self.data is not None and self.raw_data is not None:
|
||||
raise ValueError(
|
||||
"Cannot set both 'data' and 'raw_data' on the same "
|
||||
"ServerSentEvent. Use 'data' for JSON-serialized payloads "
|
||||
"or 'raw_data' for pre-formatted strings."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
def _split_sse_lines(value: str) -> list[str]:
|
||||
# Split on SSE-spec line terminators only (\n, \r\n, \r), preserving
|
||||
# trailing empty strings.
|
||||
return value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
|
||||
|
||||
def format_sse_event(
|
||||
*,
|
||||
data_str: Annotated[
|
||||
str | None,
|
||||
Doc(
|
||||
"""
|
||||
Pre-serialized data string to use as the `data:` field.
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
event: Annotated[
|
||||
str | None,
|
||||
Doc(
|
||||
"""
|
||||
Optional event type name (`event:` field).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
id: Annotated[
|
||||
str | None,
|
||||
Doc(
|
||||
"""
|
||||
Optional event ID (`id:` field).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
retry: Annotated[
|
||||
int | None,
|
||||
Doc(
|
||||
"""
|
||||
Optional reconnection time in milliseconds (`retry:` field).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
comment: Annotated[
|
||||
str | None,
|
||||
Doc(
|
||||
"""
|
||||
Optional comment line(s) (`:` prefix).
|
||||
"""
|
||||
),
|
||||
] = None,
|
||||
) -> bytes:
|
||||
"""Build SSE wire-format bytes from **pre-serialized** data.
|
||||
|
||||
The result always ends with `\\n\\n` (the event terminator).
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
if comment is not None:
|
||||
for line in _split_sse_lines(comment):
|
||||
lines.append(f": {line}")
|
||||
|
||||
if event is not None:
|
||||
lines.append(f"event: {event}")
|
||||
|
||||
if data_str is not None:
|
||||
for line in _split_sse_lines(data_str):
|
||||
lines.append(f"data: {line}")
|
||||
|
||||
if id is not None:
|
||||
lines.append(f"id: {id}")
|
||||
|
||||
if retry is not None:
|
||||
lines.append(f"retry: {retry}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
return "\n".join(lines).encode("utf-8")
|
||||
|
||||
|
||||
# Keep-alive comment, per the SSE spec recommendation
|
||||
KEEPALIVE_COMMENT = b": ping\n\n"
|
||||
|
||||
# Seconds between keep-alive pings when a generator is idle.
|
||||
# Private but importable so tests can monkeypatch it.
|
||||
_PING_INTERVAL: float = 15.0
|
||||
Reference in New Issue
Block a user