Add main.py
This commit is contained in:
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Main entry point for the Structured Output Union events API task.
|
||||||
|
|
||||||
|
This script demonstrates how to parse a raw log containing multiple HTTP events
|
||||||
|
using Pydantic v2 models and LangChain structured output. It can be run as a
|
||||||
|
stand‑alone CLI or imported as a module.
|
||||||
|
|
||||||
|
Requirements (see requirements.txt):
|
||||||
|
- langchain-core>=1.0.0
|
||||||
|
- langchain-openai
|
||||||
|
- pydantic>=2.0
|
||||||
|
- python-dotenv
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain_core.output_parsers import PydanticOutputParser
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class HttpOkEvent(BaseModel):
|
||||||
|
"""Represents a successful HTTP request."""
|
||||||
|
|
||||||
|
kind: str = Field("ok", description="Discriminator for the union.")
|
||||||
|
status: int = Field(200, description="HTTP status code (must be 200).")
|
||||||
|
path: str = Field(..., description="Requested URL path.")
|
||||||
|
duration_ms: int = Field(..., description="Duration of the request in milliseconds.")
|
||||||
|
|
||||||
|
class HttpErrorEvent(BaseModel):
|
||||||
|
"""Represents a failed HTTP request."""
|
||||||
|
|
||||||
|
kind: str = Field("error", description="Discriminator for the union.")
|
||||||
|
status: int = Field(..., ge=400, le=599, description="HTTP error status code.")
|
||||||
|
path: str = Field(..., description="Requested URL path.")
|
||||||
|
error_message: str = Field(..., description="Human readable error message.")
|
||||||
|
|
||||||
|
# Union with discriminator ``kind``. Pydantic v2 automatically uses the field
|
||||||
|
# named ``kind`` to decide which model to instantiate.
|
||||||
|
ApiEvent = Union[HttpOkEvent, HttpErrorEvent]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parser helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
parser = PydanticOutputParser(pydantic_object=ApiEvent)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
load_dotenv() # Load JARVIS API key from .env if present.
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
model="openai/gpt-oss-20b:free",
|
||||||
|
base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1",
|
||||||
|
api_key=os.getenv("JOURNAL_MCP_PAT"),
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def parse_event(raw: str) -> ApiEvent:
|
||||||
|
"""Parse a single raw log line into an :class:`ApiEvent`.
|
||||||
|
|
||||||
|
The function sends the raw text to the LLM with a structured output prompt
|
||||||
|
and then validates the result using Pydantic. If validation fails, the
|
||||||
|
exception is propagated so that callers can decide how to handle it.
|
||||||
|
"""
|
||||||
|
# Build a simple prompt that instructs the model to return JSON matching
|
||||||
|
# one of the two event schemas.
|
||||||
|
prompt = f"Parse the following log line into JSON:\n{raw}\nJSON:" # noqa: E501
|
||||||
|
response = llm.invoke([prompt])
|
||||||
|
json_text = parser.parse(response.content)
|
||||||
|
try:
|
||||||
|
return ApiEvent.model_validate(json_text) # type: ignore[arg-type]
|
||||||
|
except ValidationError as exc: # pragma: no cover - defensive
|
||||||
|
raise ValueError(f"Failed to validate event: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log(raw_log: str) -> List[ApiEvent]:
|
||||||
|
"""Parse a multiline log into a list of :class:`ApiEvent` objects.
|
||||||
|
|
||||||
|
The function splits the input on newlines and ignores empty lines.
|
||||||
|
Each non‑empty line is parsed individually.
|
||||||
|
"""
|
||||||
|
events: List[ApiEvent] = []
|
||||||
|
for line in raw_log.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = parse_event(line)
|
||||||
|
events.append(event)
|
||||||
|
except Exception as exc: # pragma: no cover - log and skip
|
||||||
|
print(f"Warning: could not parse line '{line}': {exc}", file=sys.stderr)
|
||||||
|
return events
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main() -> None:
|
||||||
|
parser_cli = argparse.ArgumentParser(description="Parse raw HTTP logs into structured events.")
|
||||||
|
parser_cli.add_argument(
|
||||||
|
"--log-file",
|
||||||
|
type=Path,
|
||||||
|
help="Path to a file containing the raw log. If omitted, a sample log is used.",
|
||||||
|
)
|
||||||
|
args = parser_cli.parse_args()
|
||||||
|
|
||||||
|
if args.log_file and args.log_file.exists():
|
||||||
|
raw_log = args.log_file.read_text(encoding="utf-8")
|
||||||
|
else:
|
||||||
|
# Sample log with mixed success and error events.
|
||||||
|
raw_log = """
|
||||||
|
GET /api/users 200 OK duration=123ms
|
||||||
|
POST /api/login 404 Not Found: user not found
|
||||||
|
PUT /api/items/42 500 Internal Server Error: database timeout
|
||||||
|
GET /health 200 OK duration=45ms
|
||||||
|
"""
|
||||||
|
print("Using sample log:\n", raw_log)
|
||||||
|
|
||||||
|
events = parse_log(raw_log)
|
||||||
|
if not events:
|
||||||
|
print("No valid events parsed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Pretty‑print the results as a table.
|
||||||
|
header = f"{'Kind':<6} | {'Path':<20} | {'Status':<6} | Details"
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
for ev in events:
|
||||||
|
if isinstance(ev, HttpOkEvent):
|
||||||
|
details = f"duration={ev.duration_ms}ms"
|
||||||
|
else: # HttpErrorEvent
|
||||||
|
details = f"error='{ev.error_message}'"
|
||||||
|
print(f"{ev.kind:<6} | {ev.path:<20} | {ev.status:<6} | {details}")
|
||||||
|
|
||||||
|
# Also output the raw Pydantic model dumps for debugging.
|
||||||
|
print("\nModel dumps:\n")
|
||||||
|
for ev in events:
|
||||||
|
print(ev.model_dump_json(indent=2))
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - entry point
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user