Store timestamps as UTC-aware and slim the knowledge context to headlines

タイムゾーン:
SQLite の DATETIME は tzinfo を保存できず、DateTime(timezone=True) を
指定してもバインド時に捨てられるため、API のレスポンスがオフセット無しの
ISO 文字列になりフロントがローカル時刻として誤解していた。UtcDateTime
(TypeDecorator) を追加し、書き込み時は naive な UTC に正規化、読み出し時は
UTC の aware として返すようにする。格納フォーマットは変わらないので既存
データはそのまま正しく解釈でき、マイグレーションは不要。
コンテナは TZ 未設定で UTC だったため、天気通知の cron 時刻・深夜スキップ
判定・「明日」の日付計算が9時間ずれていた。TZ=Asia/Tokyo を指定し、slim
イメージに無い tzdata を導入する。

蓄積知識:
層1が最新15件の要約全文を毎リクエスト無条件に注入しており、同じ内容が
ローカルKB(層3)にも保存されているため重複していた。層1を「クエリ+
一行要約」の目次に変え、本文は read_knowledge ツールで読み出す形にする。
一行要約は要約生成時の既存 LLM 呼び出しに相乗りするので呼び出し回数は
増えない。headline を持たない既存エントリは本文の先頭行から導出する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 14:22:59 +09:00
co-authored by Claude Opus 5
parent 8b2231ea97
commit be3af44891
13 changed files with 427 additions and 49 deletions
+5
View File
@@ -1,5 +1,10 @@
FROM python:3.12-slim
# slim イメージには /usr/share/zoneinfo が無く、TZ を設定してもUTCのままになるため入れる。
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv
WORKDIR /app
+2 -2
View File
@@ -224,9 +224,9 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
]
yield sse({"type": "sources", "value": sources})
for s in searches:
summary = await knowledge.summarize_results(s["query"], s["results"])
headline, summary = await knowledge.summarize_results(s["query"], s["results"])
if summary:
knowledge.save_knowledge(db, s["query"], summary, s["results"])
knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
local_knowledge.save_search_result(s["query"], summary, s["results"])
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
+3
View File
@@ -41,6 +41,9 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
# read_note ツールが1回に返す本文の最大文字数
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
# read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数
KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000"))
# Amazon Bedrock
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
+10 -4
View File
@@ -116,17 +116,21 @@ async def resolve_tool_calls(
) -> tuple[list[dict], list[dict], bool]:
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が
渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず
元のmessagesをそのまま返す。
扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
何もせず元のmessagesをそのまま返す。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
"""
# app.core.memory_kb.knowledge は app.core.llm を import しているため、
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
from app.core.memory_kb.knowledge import KNOWLEDGE_TOOLS, execute_knowledge_tool
used_model = model or DEFAULT_MODEL
working_messages = list(messages)
searches: list[dict] = []
notes_changed = False
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else [])
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS + KNOWLEDGE_TOOLS if db is not None else [])
for _ in range(max_rounds):
try:
@@ -168,6 +172,8 @@ async def resolve_tool_calls(
if name in ("read_note", "write_note") and db is not None:
tool_text, changed = execute_note_tool(db, name, args)
notes_changed = notes_changed or changed
elif name == "read_knowledge" and db is not None:
tool_text = execute_knowledge_tool(db, name, args)
else:
query = args.get("query", "")
results = await search_web(query)
+135 -17
View File
@@ -7,7 +7,11 @@ import trafilatura
from sqlalchemy.orm import Session as DBSession
from app.core import llm
from app.core.config import KNOWLEDGE_CONTEXT_LIMIT, SEARCH_SUMMARY_MODEL
from app.core.config import (
KNOWLEDGE_CONTEXT_LIMIT,
KNOWLEDGE_TOOL_CONTENT_LIMIT,
SEARCH_SUMMARY_MODEL,
)
from app.core.memory_kb import local_knowledge
from app.db.models import KnowledgeEntry, KnowledgeSource
@@ -19,6 +23,9 @@ _HTML_TAG_RE = re.compile(r"<[^>]+>")
_SUMMARY_PROMPT = (
"以下はWeb検索「{query}」で得られた複数の検索結果です。"
"これらの内容を整理し、Markdown形式で構造的にまとめてください。\n"
"- 1行目は「要約: 」で始まる60文字以内の一文にする"
"(何についての知識かが一目で分かる見出し。システムプロンプトにはこの行だけを載せる)\n"
"- 2行目以降を本文とし、以下の要領でまとめる\n"
"- 検索結果間で重複する情報はまとめ、矛盾する情報があれば併記する\n"
"- 見出し(##)やトピックごとの箇条書きを使い、要点が一目で分かるようにする\n"
"- 各項目の末尾に (出典: サイト名) を付ける\n"
@@ -27,15 +34,46 @@ _SUMMARY_PROMPT = (
)
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> str:
_HEADLINE_MARKERS = ("要約:", "要約:")
def _split_headline(text: str) -> tuple[str, str]:
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
lines = text.strip().splitlines()
if not lines:
return "", ""
first = lines[0].strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
for marker in _HEADLINE_MARKERS:
if not first.startswith(marker):
continue
headline = first[len(marker):].strip().strip("*").strip()
body = "\n".join(lines[1:]).strip()
if headline and body:
return headline[:120], body
break
return "", text.strip()
def derive_headline(query: str, summary: str) -> str:
"""headline を持たないエントリ(この機能より前に保存されたもの)のための代替の見出し。"""
for line in summary.splitlines():
stripped = line.strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
if stripped:
return stripped[:120]
return query
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> tuple[str, str]:
"""Web検索結果(title/url/content のリスト)をLLMで構造的なMarkdown要約にまとめる。
要約に失敗した場合は空文字を返す(生のスクレイピング結果を未加工のままファイルに
戻り値は (一行要約, 本文)。一行要約はシステムプロンプトに並べる見出しとして使い、
本文はDBとローカルナレッジベースに保存する。
要約に失敗した場合は ("", "") を返す(生のスクレイピング結果を未加工のままファイルに
書き込むと、ページに残ったスクリプト断片等がセキュリティソフトに誤検知されることがあるため、
フォールバックとして生テキストを書き出すことはしない)。
"""
if not results:
return ""
return "", ""
raw = "\n\n".join(
f"### {r.get('title') or r['url']}\n{r.get('content', '')[:1500]}"
@@ -43,17 +81,20 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
if r.get("content")
)
if not raw:
return ""
return "", ""
prompt = _SUMMARY_PROMPT.format(query=query, results=raw)
try:
return await llm.complete(
text = await llm.complete(
[{"role": "user", "content": prompt}],
model=model or SEARCH_SUMMARY_MODEL,
)
except Exception:
logger.exception("Failed to summarize search results for query=%r", query)
return ""
return "", ""
headline, body = _split_headline(text)
return headline or derive_headline(query, body), body
def _strip_tags(html: str) -> str:
@@ -111,12 +152,13 @@ async def regenerate_from_sources(db: DBSession) -> int:
if not results:
continue
new_summary = await summarize_results(entry.query, results)
new_headline, new_summary = await summarize_results(entry.query, results)
if not new_summary:
continue
old_summary = entry.summary
entry.summary = new_summary
entry.headline = new_headline or derive_headline(entry.query, new_summary)
db.commit()
local_knowledge.delete_search_result_file(entry.query, old_summary)
@@ -129,9 +171,11 @@ async def regenerate_from_sources(db: DBSession) -> int:
return count
def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry:
def save_knowledge(
db: DBSession, query: str, summary: str, sources: list[dict], headline: str = "",
) -> KnowledgeEntry:
"""Web検索で得た知見をDBに保存する。"""
entry = KnowledgeEntry(query=query, summary=summary)
entry = KnowledgeEntry(query=query, summary=summary, headline=headline or derive_headline(query, summary))
db.add(entry)
db.flush()
for src in sources:
@@ -143,8 +187,80 @@ def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict])
return entry
READ_KNOWLEDGE_TOOL = {
"type": "function",
"function": {
"name": "read_knowledge",
"description": "過去のWeb検索で蓄積した知識の本文を読み出す。システムプロンプトに提示された"
"「調べた事柄の一覧」の中に今の話題に関係しそうな項目があり、その本文がまだ提示されていないときに使う。"
"ここで読めるならWeb検索をやり直す必要はない。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "読み出す項目のクエリ(一覧の「」内に載っているもの)"},
},
"required": ["query"],
},
},
}
KNOWLEDGE_TOOLS = [READ_KNOWLEDGE_TOOL]
def _find_entry(db: DBSession, query: str) -> KnowledgeEntry | None:
"""クエリで蓄積知識を探す。完全一致 → 大文字小文字・前後空白を無視した一致 → 部分一致 の順に試す。
同じクエリで複数回検索されている場合は、最も新しいものを返す。
"""
entries = db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).all()
for entry in entries:
if entry.query == query:
return entry
normalized = query.strip().casefold()
for entry in entries:
if entry.query.strip().casefold() == normalized:
return entry
for entry in entries:
if normalized and normalized in entry.query.strip().casefold():
return entry
return None
def read_knowledge(db: DBSession, query: str) -> str:
"""蓄積知識の本文をツール結果用のテキストとして返す。"""
entry = _find_entry(db, query)
if not entry:
available = ", ".join(
e.query
for e in db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).limit(KNOWLEDGE_CONTEXT_LIMIT)
)
return f"「{query}」に一致する知識はありません。読み出せる項目: {available or '(なし)'}"
content = entry.summary
if len(content) > KNOWLEDGE_TOOL_CONTENT_LIMIT:
content = content[:KNOWLEDGE_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
return f"「{entry.query}」について調べた内容:\n\n{content}\n\n(出典: {urls})"
def execute_knowledge_tool(db: DBSession, name: str, args: dict) -> str:
"""蓄積知識のツールを実行する。ノート系と違いDBを変更しないため、戻り値はテキストのみ。"""
try:
if name == "read_knowledge":
return read_knowledge(db, args.get("query", ""))
except Exception as e:
logger.exception("Knowledge tool %s failed", name)
return f"知識の読み出しに失敗しました: {e}"
return f"未知のツールです: {name}"
def get_knowledge_context(db: DBSession) -> str | None:
"""蓄積された知識を、システムプロンプトに注入できる形のテキストにして返す。"""
"""蓄積された知識の「見出し一覧」を、システムプロンプトに注入できる形のテキストにして返す。
本文(summary)はここには含めない。同じ内容が knowledge_base/web_search/ 配下にも
Markdownとして保存されており、話題に関連していればローカルナレッジベース側が
全文を注入するため、ここで全件の本文を積むと同じ情報を二重に渡すことになる。
"""
entries = (
db.query(KnowledgeEntry)
.order_by(KnowledgeEntry.created_at.desc())
@@ -154,12 +270,14 @@ def get_knowledge_context(db: DBSession) -> str | None:
if not entries:
return None
blocks = []
for entry in reversed(entries):
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})")
blocks = [
f"- 「{entry.query}」: {entry.headline or derive_headline(entry.query, entry.summary)}"
for entry in reversed(entries)
]
return (
"以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください。"
"ただしローカルナレッジベースの資料が別途提示されている場合は、そちらを優先してください:\n\n" + "\n".join(blocks)
"以下はこれまでのWeb検索で調べた事柄の一覧です(見出しのみで、本文は含みません)。\n"
"関連する話題が出たときは、まず本文がローカルナレッジベースの資料として別途提示されていないか確認し、"
"あればそれを本文として使ってください。提示されていない場合は read_knowledge ツールで読み出せます。"
"いずれかで足りるならWeb検索をやり直す必要はありません:\n\n" + "\n".join(blocks)
)
+3 -2
View File
@@ -1,5 +1,5 @@
"""直近で取得した「明日の天気」データをメモリ上にキャッシュする。"""
from datetime import datetime
from datetime import datetime, timezone
_latest: dict | None = None
@@ -11,7 +11,8 @@ def set_latest(weather: str, pop: int | None, judgement: str, reason: str) -> No
"pop": pop,
"judgement": judgement,
"reason": reason,
"fetched_at": datetime.now().isoformat(),
# オフセット付きで返す(フロントの new Date() がローカル時刻と誤解しないようにするため)
"fetched_at": datetime.now(timezone.utc).isoformat(),
}
+12 -4
View File
@@ -18,14 +18,22 @@ def get_db():
db.close()
# 既存DBに後から追加されたカラム: (テーブル名, カラム名, 型)
_ADDED_COLUMNS = [
("note_pages", "section", "VARCHAR"),
("knowledge_entries", "headline", "VARCHAR"),
]
def _add_missing_columns() -> None:
"""マイグレーションフレームワークを使っていないため、既存DBに後から追加されたカラムを
ここで簡易的に補う。create_all() は既存テーブルへのカラム追加は行わないので必要。"""
with engine.connect() as conn:
existing = {row[1] for row in conn.execute(text("PRAGMA table_info(note_pages)"))}
if existing and "section" not in existing:
conn.execute(text("ALTER TABLE note_pages ADD COLUMN section VARCHAR"))
conn.commit()
for table, column, column_type in _ADDED_COLUMNS:
existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
if existing and column not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}"))
conn.commit()
def init_db():
+18 -14
View File
@@ -1,7 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float
from sqlalchemy import String, Text, ForeignKey, Integer, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
from app.db.types import UtcDateTime
class Session(Base):
@@ -10,8 +11,8 @@ class Session(Base):
id: Mapped[str] = mapped_column(String, primary_key=True)
title: Mapped[str] = mapped_column(String, default="New Chat")
model: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at")
memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan")
@@ -24,7 +25,7 @@ class Message(Base):
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system"
content: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
session: Mapped["Session"] = relationship(back_populates="messages")
tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan")
@@ -36,7 +37,7 @@ class MessageTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"))
tag: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
message: Mapped["Message"] = relationship(back_populates="tags")
@@ -48,7 +49,7 @@ class Memory(Base):
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
summary: Mapped[str] = mapped_column(Text)
embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
session: Mapped["Session"] = relationship(back_populates="memories")
tags: Mapped[list["MemoryTag"]] = relationship(back_populates="memory", cascade="all, delete-orphan")
@@ -60,7 +61,7 @@ class MemoryTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE"))
tag: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
memory: Mapped["Memory"] = relationship(back_populates="tags")
@@ -72,7 +73,10 @@ class KnowledgeEntry(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
query: Mapped[str] = mapped_column(String)
summary: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
# システムプロンプトには本文ではなくこの一行要約だけを並べる(app.core.memory_kb.knowledge を参照)。
# このカラム追加より前に作られた行では None になるため、表示側で summary から導出する。
headline: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan")
@@ -101,7 +105,7 @@ class LocalKnowledgeDoc(Base):
title: Mapped[str] = mapped_column(String)
content_hash: Mapped[str] = mapped_column(String)
embedding_json: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class NotePage(Base):
@@ -117,8 +121,8 @@ class NotePage(Base):
content: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順(セクションでまとめる際、同じセクションのページは連続させる)
section: Mapped[str | None] = mapped_column(String, nullable=True) # タブをグループ化するセクション名。未設定なら未分類
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class AppSetting(Base):
@@ -127,7 +131,7 @@ class AppSetting(Base):
key: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class ModelPreset(Base):
@@ -137,7 +141,7 @@ class ModelPreset(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
label: Mapped[str] = mapped_column(String)
value: Mapped[str] = mapped_column(String, unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
class ApiLog(Base):
@@ -150,4 +154,4 @@ class ApiLog(Base):
response_text: Mapped[str | None] = mapped_column(Text, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
+38
View File
@@ -0,0 +1,38 @@
"""タイムゾーンを保ったまま日時を扱うためのカラム型。"""
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.types import TypeDecorator
class UtcDateTime(TypeDecorator):
"""常に UTC の aware な datetime として読み書きする DateTime。
SQLite の DATETIME は tzinfo を保存できず、`DateTime(timezone=True)` を指定しても
バインド時に黙って捨てられる(=naive な値が返ってくる)。そのままだと API のレスポンスが
オフセット無しの ISO 文字列になり、フロントの `new Date()` がローカル時刻として
解釈してしまうため、この型で入口と出口を揃える。
- 書き込み: aware な値は UTC に変換し、naive な値は UTC とみなす。いずれも naive な UTC として格納する
- 読み出し: 格納値を UTC の aware な datetime として返す
格納フォーマットは素の DateTime と同じ(naive な UTC)なので、
既存DBのデータはそのまま正しく解釈でき、マイグレーションは不要。
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def process_result_value(self, value: datetime | None, dialect) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)