feat: solution for 'Повторный экзамен: Structured output — Union событий API'
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
@@ -0,0 +1,95 @@
|
||||
# Structured Log Parser
|
||||
|
||||
This project demonstrates how to parse raw log lines into typed events using **Pydantic v2** and **LangChain**'s structured output.
|
||||
It supports two event types:
|
||||
|
||||
| Event | Fields |
|
||||
|-------|--------|
|
||||
| `HttpOkEvent` | `kind="ok"`, `status=200`, `path`, `duration_ms` |
|
||||
| `HttpErrorEvent` | `kind="error"`, `status` (4xx/5xx), `path`, `error_message` |
|
||||
|
||||
The parser uses a LangChain prompt to convert each log line into a JSON object that matches one of the schemas. The output is then validated with Pydantic.
|
||||
|
||||
## Features
|
||||
|
||||
- **Discriminated Union**: `ApiEvent` is a union of `HttpOkEvent` and `HttpErrorEvent` with a `kind` discriminator.
|
||||
- **Structured Output**: Uses LangChain's `PydanticOutputParser` to enforce schema.
|
||||
- **Batch Parsing**: Handles multiple log lines in a single run.
|
||||
- **CLI**: Accepts example logs, a file, or custom text and prints a table of parsed events.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/yourusername/structured-log-parser.git
|
||||
cd structured-log-parser
|
||||
|
||||
# Create a virtual environment (optional but recommended)
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Using the built‑in example
|
||||
|
||||
```bash
|
||||
python -m src.main --example
|
||||
```
|
||||
|
||||
### 2. Parsing a log file
|
||||
|
||||
```bash
|
||||
python -m src.main --file path/to/log.txt
|
||||
```
|
||||
|
||||
### 3. Parsing custom text
|
||||
|
||||
```bash
|
||||
python -m src.main --text "2026-08-31 12:00:01 INFO /api/users 200 123ms"
|
||||
```
|
||||
|
||||
The output will be a Markdown‑style table:
|
||||
|
||||
```
|
||||
| kind | path | status | duration_ms / error_message |
|
||||
|------|---------------|--------|-----------------------------|
|
||||
| ok | /api/users | 200 | 123 ms |
|
||||
| error| /api/orders | 404 | Not Found |
|
||||
| error| /api/payments | 500 | Internal Server Error |
|
||||
| ok | /api/products | 200 | 45 ms |
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The project uses OpenAI's API. Set the following variable before running:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
```
|
||||
|
||||
Alternatively, create a `.env` file in the project root:
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
The `python-dotenv` package will load it automatically.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── __init__.py
|
||||
├── main.py # CLI entry point
|
||||
├── cli.py # Argument parsing and table rendering
|
||||
├── parser.py # Log parsing logic
|
||||
└── models.py # Pydantic event models
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,5 @@
|
||||
langchain-core>=0.2.0
|
||||
langchain-openai>=0.2.0
|
||||
pydantic>=2.0
|
||||
tabulate>=0.9.0
|
||||
python-dotenv>=1.0.0
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from tabulate import tabulate
|
||||
|
||||
from .parser import parse_log
|
||||
from .models import ApiEvent
|
||||
|
||||
EXAMPLE_LOG = textwrap.dedent(
|
||||
"""\
|
||||
2026-08-31 12:00:01 INFO /api/users 200 123ms
|
||||
2026-08-31 12:00:02 ERROR /api/orders 404 Not Found
|
||||
2026-08-31 12:00:03 WARN /api/payments 500 Internal Server Error
|
||||
2026-08-31 12:00:04 INFO /api/products 200 45ms
|
||||
"""
|
||||
)
|
||||
|
||||
def build_table(events: List[ApiEvent]) -> str:
|
||||
headers = ["kind", "path", "status", "duration_ms / error_message"]
|
||||
rows = []
|
||||
for ev in events:
|
||||
if ev.kind == "ok":
|
||||
rows.append([ev.kind, ev.path, ev.status, f"{ev.duration_ms} ms"])
|
||||
else:
|
||||
rows.append([ev.kind, ev.path, ev.status, ev.error_message])
|
||||
return tabulate(rows, headers=headers, tablefmt="github")
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Parse raw log lines into structured API events using LangChain."
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"--example",
|
||||
action="store_true",
|
||||
help="Use built-in example log",
|
||||
)
|
||||
group.add_argument(
|
||||
"--file",
|
||||
type=Path,
|
||||
help="Path to a file containing raw log lines",
|
||||
)
|
||||
group.add_argument(
|
||||
"--text",
|
||||
type=str,
|
||||
help="Custom log text (enclosed in quotes)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.example:
|
||||
log_text = EXAMPLE_LOG
|
||||
elif args.file:
|
||||
log_text = args.file.read_text(encoding="utf-8")
|
||||
elif args.text:
|
||||
log_text = args.text
|
||||
else:
|
||||
log_text = EXAMPLE_LOG
|
||||
|
||||
events = parse_log(log_text)
|
||||
print(build_table(events))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry point for the log parser CLI."""
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Annotated, Union
|
||||
|
||||
from pydantic import BaseModel, Field, FieldValidationError, ValidationError, field_validator
|
||||
|
||||
# Base event model with discriminator field
|
||||
class BaseEvent(BaseModel):
|
||||
kind: Literal["ok", "error"] = Field(..., description="Event kind discriminator")
|
||||
|
||||
# OK event
|
||||
class HttpOkEvent(BaseEvent):
|
||||
kind: Literal["ok"] = Field("ok", description="OK event kind")
|
||||
status: Literal[200] = Field(..., description="HTTP status code")
|
||||
path: str = Field(..., description="Request path")
|
||||
duration_ms: int = Field(..., description="Duration in milliseconds")
|
||||
|
||||
# Error event
|
||||
class HttpErrorEvent(BaseEvent):
|
||||
kind: Literal["error"] = Field("error", description="Error event kind")
|
||||
status: int = Field(..., description="HTTP status code (4xx or 5xx)")
|
||||
path: str = Field(..., description="Request path")
|
||||
error_message: str = Field(..., description="Error message")
|
||||
|
||||
# Union with discriminator
|
||||
ApiEvent = Annotated[
|
||||
Union[HttpOkEvent, HttpErrorEvent],
|
||||
Field(discriminator="kind")
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterable, List
|
||||
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.prompts import PromptTemplate
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from .models import ApiEvent
|
||||
|
||||
# Load OpenAI key from environment
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError("OPENAI_API_KEY environment variable not set")
|
||||
|
||||
# LLM
|
||||
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, api_key=OPENAI_API_KEY)
|
||||
|
||||
# Structured output parser
|
||||
parser = PydanticOutputParser(pydantic_object=ApiEvent)
|
||||
|
||||
# Prompt template for parsing a single log line
|
||||
prompt_template = PromptTemplate(
|
||||
input_variables=["log_line"],
|
||||
template=(
|
||||
"You are a log parser. Convert the following raw log line into a structured JSON object "
|
||||
"matching one of the following schemas:\n\n"
|
||||
"1. OK event: {{ ok_schema }}\n"
|
||||
"2. Error event: {{ error_schema }}\n\n"
|
||||
"The output must be valid JSON and must include a field `kind` with value `ok` or `error`.\n\n"
|
||||
"Raw log line:\n"
|
||||
"{{ log_line }}\n\n"
|
||||
"Output JSON:"
|
||||
),
|
||||
)
|
||||
|
||||
# Fill in schema descriptions
|
||||
prompt_template = prompt_template.partial(
|
||||
ok_schema=parser.get_format_instructions(),
|
||||
error_schema=parser.get_format_instructions(),
|
||||
)
|
||||
|
||||
def parse_line(line: str) -> ApiEvent:
|
||||
"""
|
||||
Parse a single log line into an ApiEvent using LangChain structured output.
|
||||
"""
|
||||
# Build prompt
|
||||
prompt = prompt_template.format(log_line=line.strip())
|
||||
# Send to LLM
|
||||
response = llm.invoke([HumanMessage(content=prompt)])
|
||||
# Parse JSON
|
||||
try:
|
||||
event = parser.parse(response.content)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Failed to parse line: {line!r}\nLLM response: {response.content}\nError: {exc}") from exc
|
||||
return event
|
||||
|
||||
def parse_log(text: str) -> List[ApiEvent]:
|
||||
"""
|
||||
Parse a multiline log string into a list of ApiEvent objects.
|
||||
"""
|
||||
events: List[ApiEvent] = []
|
||||
for line in text.strip().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
events.append(parse_line(line))
|
||||
return events
|
||||
Reference in New Issue
Block a user