vinc-langgraph 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ # 예외: 디자인 허브 산출물은 **계약**이라 추적한다. 소비 리포의 사본
7
+ # (app/src/tokens.*.css·theme.ts)이 커밋돼 있으므로 그 비교 대상도 리포에 있어야
8
+ # `sync_out.py --check`(CI 드리프트 게이트)가 성립한다. 없으면 게이트가
9
+ # "[MISSING·허브]" 로 죽는다 — 2026-08-08 CI 실패의 원인.
10
+ !design-hub/dist/
11
+ # 단, Claude Design 푸시 번들은 제외 — 업로드 직전에 만드는 임시 산출물이고
12
+ # 대조할 커밋된 소비 사본이 없다(그쪽은 claude.ai 프로젝트에 산다).
13
+ design-hub/dist/claude-design/
14
+ build/
15
+ *.egg-info/
16
+ models/
17
+ vinc_store
18
+ *.lbug
19
+ vinc_e2e/
20
+ node_modules/
21
+ app/src-tauri/target/
22
+ app/src-tauri/binaries/
23
+ app/src-tauri/gen/
24
+ # .claude/ 는 기기별 설정(launch.json·ui-events.jsonl·worktrees)이라 **어느 깊이에서도**
25
+ # 추적하지 않는다. 예외는 이제 없다. 역할 껍데기는 `agents/shells/` 에서 추적되고
26
+ # (`agents_sync.py --check` 가 CI 에서 대조), 하네스가 읽는 사본은 리포 밖 워크스페이스
27
+ # 루트에 `--install` 이 쓴다. 리포 안 `.claude/agents` 는 체크아웃의 브랜치를 따라
28
+ # 수가 달라지는 두 번째 로스터였다.
29
+ # ⚠ 전에는 루트의 `.claude/agents/` 만 되살리려고 네 줄을 겹쳐 썼다(한 줄로는 예외가
30
+ # 사문이 되거나 `_migration/**/.claude/` 중첩본이 새어 나갔다, 2026-08-23 PR #69).
31
+ # 예외가 사라졌으니 모든 깊이를 잡는 이 한 줄이면 된다.
32
+ .claude/
33
+ # 서명·접속 키 — 커밋되면 되돌릴 수 없다. 지금 키들은 리포 **바깥**(워크스페이스
34
+ # 루트)에 있어 커밋된 적이 없지만, 누가 안으로 옮겨도 막히도록 패턴을 박아 둔다.
35
+ # 업데이터 개인키를 잃으면 배포된 전 사용자가 자동 업데이트에서 영구 이탈한다
36
+ # (복구 경로 없음 — DEPLOYMENT.md §1.6 백업 절차 참조).
37
+ *.key
38
+ *.pem
39
+ *.p12
40
+ *.pfx
41
+
42
+ # uv 캐시 락파일 — 로컬 테스트 실행(`uv run`)이 만든다. 정본 의존성은
43
+ # pyproject.toml 이고, 이 락은 기기별 파생물이라 추적하지 않는다.
44
+ uv.lock
45
+
46
+ # 평가·리허설 부산물 — 모델 가중치 수백 MB 가 여기 앉는다.
47
+ # 이 워킹트리는 세션 공유라 남의 `git add -A` 가 이것을 집는다.
48
+ .cache/
49
+ work/
50
+
51
+ # design-hub capture PNGs are build products of scripts/capture_*.mjs; the manifests are committed
52
+ design-hub/reports/captures/**/*.png
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: vinc-langgraph
3
+ Version: 0.1.0
4
+ Summary: Vinc for LangGraph and LangChain agents: bring decisions and records from your knowledge graph into graph nodes and model calls, and record an episode only when you choose to.
5
+ Project-URL: Homepage, https://vincs.io
6
+ Project-URL: Documentation, https://vincs.io/docs/
7
+ Author: Vinculums
8
+ License-Expression: MIT
9
+ Keywords: knowledge-graph,langchain,langgraph,memory,middleware,vinc
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: langchain-core>=1.0
16
+ Requires-Dist: langgraph>=1.0
17
+ Requires-Dist: vinc-client<0.2,>=0.1
18
+ Provides-Extra: agents
19
+ Requires-Dist: langchain>=1.0; extra == 'agents'
20
+ Provides-Extra: test
21
+ Requires-Dist: langchain>=1.0; extra == 'test'
22
+ Requires-Dist: pytest>=8; extra == 'test'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # vinc-langgraph
26
+
27
+ [Vinc](https://vincs.io) for [LangGraph](https://langchain-ai.github.io/langgraph/) and LangChain agents. Vinc is a knowledge graph you share with AI: the decisions, records and documents your team wrote, with the reasons attached. This package brings the relevant part of it into your graph nodes and model calls.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install vinc-langgraph # node helpers and tools
33
+ pip install "vinc-langgraph[agents]" # plus the middleware for create_agent
34
+ ```
35
+
36
+ Set `VINC_API_KEY` to a member key. A `vinc_ro_` key is enough for everything except `record_episode`.
37
+
38
+ ## In a graph node
39
+
40
+ ```python
41
+ from vinc_client import VincClient
42
+ from vinc_langgraph import build_system_message, get_vinc_context
43
+
44
+ vinc = VincClient()
45
+
46
+ def review(state):
47
+ block = get_vinc_context(vinc, state["messages"]) # newest human message
48
+ system = build_system_message(block, "You review design changes.")
49
+ return {"messages": [llm.invoke([system, *state["messages"]])]}
50
+ ```
51
+
52
+ `get_vinc_context` makes one call, two when nothing matched or several nodes tied (it then widens with a search). It returns `None` when nothing matched, and also when Vinc is unreachable or the daily limit is spent, so the node goes on without it. A wrong key or space raises.
53
+
54
+ ## In a `create_agent` agent
55
+
56
+ ```python
57
+ from langchain.agents import create_agent
58
+ from vinc_langgraph import VincContextMiddleware
59
+
60
+ agent = create_agent("openai:gpt-5", middleware=[VincContextMiddleware()])
61
+ ```
62
+
63
+ The middleware adds the block to the system message of each model call. An agent that uses tools calls the model several times per question, so the answer is kept for 60 seconds (`cache_seconds`) instead of spending a Vinc call each time.
64
+
65
+ ## Tools the model can call
66
+
67
+ ```python
68
+ from vinc_langgraph import create_vinc_tools
69
+
70
+ tools = create_vinc_tools(VincClient()) # vinc_brief and vinc_search, read only
71
+ ```
72
+
73
+ ## Recording, after a person approved
74
+
75
+ Nothing in this package writes on its own. Put `record_episode` in the node after an `interrupt()`, and give only that node a `vinc_sk_` key. `examples/approve_then_record.py` is a complete graph:
76
+
77
+ ```
78
+ context -> review -> approve (interrupt) -> record
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,59 @@
1
+ # vinc-langgraph
2
+
3
+ [Vinc](https://vincs.io) for [LangGraph](https://langchain-ai.github.io/langgraph/) and LangChain agents. Vinc is a knowledge graph you share with AI: the decisions, records and documents your team wrote, with the reasons attached. This package brings the relevant part of it into your graph nodes and model calls.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install vinc-langgraph # node helpers and tools
9
+ pip install "vinc-langgraph[agents]" # plus the middleware for create_agent
10
+ ```
11
+
12
+ Set `VINC_API_KEY` to a member key. A `vinc_ro_` key is enough for everything except `record_episode`.
13
+
14
+ ## In a graph node
15
+
16
+ ```python
17
+ from vinc_client import VincClient
18
+ from vinc_langgraph import build_system_message, get_vinc_context
19
+
20
+ vinc = VincClient()
21
+
22
+ def review(state):
23
+ block = get_vinc_context(vinc, state["messages"]) # newest human message
24
+ system = build_system_message(block, "You review design changes.")
25
+ return {"messages": [llm.invoke([system, *state["messages"]])]}
26
+ ```
27
+
28
+ `get_vinc_context` makes one call, two when nothing matched or several nodes tied (it then widens with a search). It returns `None` when nothing matched, and also when Vinc is unreachable or the daily limit is spent, so the node goes on without it. A wrong key or space raises.
29
+
30
+ ## In a `create_agent` agent
31
+
32
+ ```python
33
+ from langchain.agents import create_agent
34
+ from vinc_langgraph import VincContextMiddleware
35
+
36
+ agent = create_agent("openai:gpt-5", middleware=[VincContextMiddleware()])
37
+ ```
38
+
39
+ The middleware adds the block to the system message of each model call. An agent that uses tools calls the model several times per question, so the answer is kept for 60 seconds (`cache_seconds`) instead of spending a Vinc call each time.
40
+
41
+ ## Tools the model can call
42
+
43
+ ```python
44
+ from vinc_langgraph import create_vinc_tools
45
+
46
+ tools = create_vinc_tools(VincClient()) # vinc_brief and vinc_search, read only
47
+ ```
48
+
49
+ ## Recording, after a person approved
50
+
51
+ Nothing in this package writes on its own. Put `record_episode` in the node after an `interrupt()`, and give only that node a `vinc_sk_` key. `examples/approve_then_record.py` is a complete graph:
52
+
53
+ ```
54
+ context -> review -> approve (interrupt) -> record
55
+ ```
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,4 @@
1
+ # Lowest supported framework versions; CI installs and tests this set separately.
2
+ langgraph==1.0.0
3
+ langchain==1.0.0
4
+ langchain-core==1.0.0
@@ -0,0 +1,70 @@
1
+ """A review graph that reads Vinc, stops for a person, and records only what was approved.
2
+
3
+ context -> review -> approve (interrupt) -> record
4
+
5
+ Run it against your own graph with two keys: a read-only key for the reading
6
+ nodes and a write key for the recording node only.
7
+
8
+ VINC_API_KEY=vinc_ro_... VINC_WRITE_KEY=vinc_sk_... python approve_then_record.py
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import Any, TypedDict
14
+
15
+ from langgraph.checkpoint.memory import InMemorySaver
16
+ from langgraph.graph import END, START, StateGraph
17
+ from langgraph.types import Command, interrupt
18
+ from vinc_client import VincClient
19
+
20
+ from vinc_langgraph import get_vinc_context, record_episode
21
+
22
+
23
+ class ReviewState(TypedDict, total=False):
24
+ question: str
25
+ context: str | None
26
+ draft: str
27
+ approved: bool
28
+ about: list[str]
29
+ written: dict[str, Any] | None
30
+
31
+
32
+ def build_graph(reader: VincClient, writer: VincClient, review=None):
33
+ """``review`` turns (question, context) into a draft; a model call in real use."""
34
+ review = review or (lambda question, context: f"Reviewed: {question}")
35
+
36
+ def context_node(state: ReviewState) -> ReviewState:
37
+ return {"context": get_vinc_context(reader, state["question"])}
38
+
39
+ def review_node(state: ReviewState) -> ReviewState:
40
+ return {"draft": review(state["question"], state.get("context"))}
41
+
42
+ def approve_node(state: ReviewState) -> ReviewState:
43
+ # 사람이 재개할 때까지 여기서 멈춘다. 재개 값이 참일 때만 기록한다.
44
+ return {"approved": bool(interrupt({"draft": state["draft"]}))}
45
+
46
+ def record_node(state: ReviewState) -> ReviewState:
47
+ if not state.get("approved"):
48
+ return {"written": None}
49
+ return {"written": record_episode(writer, state["draft"], about=state.get("about") or [])}
50
+
51
+ graph = StateGraph(ReviewState)
52
+ graph.add_node("context", context_node)
53
+ graph.add_node("review", review_node)
54
+ graph.add_node("approve", approve_node)
55
+ graph.add_node("record", record_node)
56
+ graph.add_edge(START, "context")
57
+ graph.add_edge("context", "review")
58
+ graph.add_edge("review", "approve")
59
+ graph.add_edge("approve", "record")
60
+ graph.add_edge("record", END)
61
+ return graph.compile(checkpointer=InMemorySaver())
62
+
63
+
64
+ if __name__ == "__main__":
65
+ app = build_graph(VincClient(), VincClient(os.environ["VINC_WRITE_KEY"]))
66
+ config = {"configurable": {"thread_id": "review-1"}}
67
+ paused = app.invoke({"question": "Does the new secondary button meet contrast on dark?",
68
+ "about": []}, config)
69
+ print("waiting for approval:", paused["__interrupt__"][0].value)
70
+ print(app.invoke(Command(resume=input("approve? [y/N] ").lower() == "y"), config))
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vinc-langgraph"
7
+ version = "0.1.0"
8
+ description = "Vinc for LangGraph and LangChain agents: bring decisions and records from your knowledge graph into graph nodes and model calls, and record an episode only when you choose to."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Vinculums" }]
13
+ keywords = ["vinc", "langgraph", "langchain", "middleware", "knowledge-graph", "memory"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = ["vinc-client>=0.1,<0.2", "langgraph>=1.0", "langchain-core>=1.0"]
21
+
22
+ [project.optional-dependencies]
23
+ agents = ["langchain>=1.0"]
24
+ test = ["pytest>=8", "langchain>=1.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://vincs.io"
28
+ Documentation = "https://vincs.io/docs/"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/vinc_langgraph"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,27 @@
1
+ """Vinc for LangGraph and LangChain agents.
2
+
3
+ Design and scope: [[vinc.plan.framework-integration-packages-2026-09-28]].
4
+ ``VincContextMiddleware`` needs the ``agents`` extra and is imported lazily.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from .helpers import (aget_vinc_context, arecord_episode, build_system_message, # noqa: E402
13
+ create_vinc_tools, get_vinc_context, last_user_text, record_episode)
14
+
15
+
16
+ def __getattr__(name: str) -> Any:
17
+ # 미들웨어는 langchain(agents 추가 설치)이 있어야 한다. 노드 헬퍼만 쓰는 사람에게
18
+ # 그 의존성을 강요하지 않으려고 이름을 처음 쓸 때 가져온다.
19
+ if name == "VincContextMiddleware":
20
+ from .middleware import VincContextMiddleware
21
+ return VincContextMiddleware
22
+ raise AttributeError(name)
23
+
24
+
25
+ __all__ = ["__version__", "get_vinc_context", "aget_vinc_context", "build_system_message",
26
+ "create_vinc_tools", "record_episode", "arecord_episode", "last_user_text",
27
+ "VincContextMiddleware"]
@@ -0,0 +1,152 @@
1
+ """Helpers for graph nodes: read context, build a system message, expose read tools, record.
2
+
3
+ None of these writes on its own. ``record_episode`` writes because you call it,
4
+ which in a graph belongs in a node after an ``interrupt()`` a person resumed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Iterable, Sequence
9
+
10
+ from langchain_core.messages import HumanMessage, SystemMessage
11
+ from langchain_core.tools import StructuredTool
12
+ from pydantic import BaseModel, Field
13
+ from vinc_client import (AsyncVincClient, VincClient, VincQuotaExceeded, VincUnavailable,
14
+ format_brief, format_search)
15
+ from vinc_client.context import DEFAULT_MAX_CHARS
16
+
17
+
18
+ def last_user_text(messages: str | Sequence[Any]) -> str:
19
+ """The newest human message's text; a plain string is returned as is."""
20
+ if isinstance(messages, str):
21
+ return messages.strip()
22
+ for message in reversed(list(messages or [])):
23
+ if isinstance(message, HumanMessage) or getattr(message, "type", "") == "human":
24
+ text = getattr(message, "text", None)
25
+ # 1.x 에서 .text 는 속성이다(호출하면 폐기 경고). 문자열이 아닌 호출체일 때만 부른다.
26
+ if callable(text) and not isinstance(text, str):
27
+ text = text()
28
+ return str(text if text is not None else message.content).strip()
29
+ return ""
30
+
31
+
32
+ def get_vinc_context(client: VincClient, messages: str | Sequence[Any], *,
33
+ search_fallback: bool = True, max_chars: int = DEFAULT_MAX_CHARS,
34
+ max_excerpts: int = 3) -> str | None:
35
+ """A fenced context block for the newest human message, or ``None``.
36
+
37
+ ``None`` also when Vinc is unavailable or the quota is spent; a key or space
38
+ problem raises.
39
+ """
40
+ return client.get_context(last_user_text(messages), search_fallback=search_fallback,
41
+ max_chars=max_chars, max_excerpts=max_excerpts)
42
+
43
+
44
+ async def aget_vinc_context(client: AsyncVincClient, messages: str | Sequence[Any], *,
45
+ search_fallback: bool = True, max_chars: int = DEFAULT_MAX_CHARS,
46
+ max_excerpts: int = 3) -> str | None:
47
+ """Async :func:`get_vinc_context`."""
48
+ return await client.get_context(last_user_text(messages), search_fallback=search_fallback,
49
+ max_chars=max_chars, max_excerpts=max_excerpts)
50
+
51
+
52
+ def build_system_message(block: str | None, base: SystemMessage | str | None = None
53
+ ) -> SystemMessage | None:
54
+ """Append ``block`` to ``base`` (or start one). Returns ``base`` unchanged when there
55
+ is no block, and ``None`` when there is neither."""
56
+ if isinstance(base, str):
57
+ base = SystemMessage(content=base)
58
+ if not block:
59
+ return base
60
+ if base is None:
61
+ return SystemMessage(content=block)
62
+ return SystemMessage(content=[*base.content_blocks, {"type": "text", "text": block}])
63
+
64
+
65
+ class _BriefArgs(BaseModel):
66
+ query: str = Field(description="A question, or an exact node id such as decision:some-slug.")
67
+
68
+
69
+ class _SearchArgs(BaseModel):
70
+ query: str = Field(description="What to look for.")
71
+ limit: int = Field(default=5, ge=1, le=20, description="How many matches of each kind.")
72
+
73
+
74
+ _DOWN = "Vinc is not available right now ({code}); answer without it."
75
+
76
+
77
+ def create_vinc_tools(client: VincClient | None = None,
78
+ async_client: AsyncVincClient | None = None, *,
79
+ max_chars: int = DEFAULT_MAX_CHARS) -> list[StructuredTool]:
80
+ """Two read-only tools, ``vinc_brief`` and ``vinc_search``.
81
+
82
+ Pass a sync client, an async client, or both; each tool gets the paths it
83
+ has a client for. Nothing here can write.
84
+ """
85
+ if client is None and async_client is None:
86
+ raise ValueError("pass a VincClient, an AsyncVincClient, or both")
87
+
88
+ def brief(query: str) -> str:
89
+ try:
90
+ return (format_brief(client.brief(query), max_chars=max_chars) # type: ignore[union-attr]
91
+ or "Vinc has no single node for that; try vinc_search.")
92
+ except (VincUnavailable, VincQuotaExceeded) as exc:
93
+ return _DOWN.format(code=exc.code)
94
+
95
+ async def abrief(query: str) -> str:
96
+ try:
97
+ return (format_brief(await async_client.brief(query), max_chars=max_chars) # type: ignore[union-attr]
98
+ or "Vinc has no single node for that; try vinc_search.")
99
+ except (VincUnavailable, VincQuotaExceeded) as exc:
100
+ return _DOWN.format(code=exc.code)
101
+
102
+ def search(query: str, limit: int = 5) -> str:
103
+ try:
104
+ return (format_search(client.search(query, limit=limit), # type: ignore[union-attr]
105
+ max_chars=max_chars, limit=limit) or "No matches in Vinc.")
106
+ except (VincUnavailable, VincQuotaExceeded) as exc:
107
+ return _DOWN.format(code=exc.code)
108
+
109
+ async def asearch(query: str, limit: int = 5) -> str:
110
+ try:
111
+ return (format_search(await async_client.search(query, limit=limit), # type: ignore[union-attr]
112
+ max_chars=max_chars, limit=limit) or "No matches in Vinc.")
113
+ except (VincUnavailable, VincQuotaExceeded) as exc:
114
+ return _DOWN.format(code=exc.code)
115
+
116
+ return [
117
+ StructuredTool.from_function(
118
+ func=brief if client else None, coroutine=abrief if async_client else None,
119
+ name="vinc_brief", args_schema=_BriefArgs,
120
+ description="Look up one node in the user's Vinc knowledge graph: a decision, "
121
+ "record or concept with its summary, document excerpts and relations."),
122
+ StructuredTool.from_function(
123
+ func=search if client else None, coroutine=asearch if async_client else None,
124
+ name="vinc_search", args_schema=_SearchArgs,
125
+ description="Search the user's Vinc knowledge graph by text and meaning; returns "
126
+ "matching nodes and document passages with their ids."),
127
+ ]
128
+
129
+
130
+ def record_episode(client: VincClient, title: str, *, summary: str = "",
131
+ about: Iterable[str] = (), date: str | None = None,
132
+ domain: str | None = None, tags: Iterable[str] | None = None,
133
+ space: str | None = None) -> dict[str, Any]:
134
+ """Write one Episode about existing concepts or decisions (needs a ``vinc_sk_`` key).
135
+
136
+ Put it in the node that runs after a person resumed an ``interrupt()``.
137
+ """
138
+ return client.record_episode(title, summary=summary, about=about, date=date,
139
+ domain=domain, tags=tags, space=space)
140
+
141
+
142
+ async def arecord_episode(client: AsyncVincClient, title: str, *, summary: str = "",
143
+ about: Iterable[str] = (), date: str | None = None,
144
+ domain: str | None = None, tags: Iterable[str] | None = None,
145
+ space: str | None = None) -> dict[str, Any]:
146
+ """Async :func:`record_episode`."""
147
+ return await client.record_episode(title, summary=summary, about=about, date=date,
148
+ domain=domain, tags=tags, space=space)
149
+
150
+
151
+ __all__ = ["last_user_text", "get_vinc_context", "aget_vinc_context",
152
+ "build_system_message", "create_vinc_tools", "record_episode", "arecord_episode"]
@@ -0,0 +1,96 @@
1
+ """Middleware for LangChain ``create_agent`` (which runs on LangGraph).
2
+
3
+ Needs the ``agents`` extra: ``pip install "vinc-langgraph[agents]"``.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from typing import Any, Awaitable, Callable
9
+
10
+ from langchain.agents.middleware import AgentMiddleware, ModelRequest
11
+ from vinc_client import AsyncVincClient, VincClient
12
+ from vinc_client.context import DEFAULT_MAX_CHARS
13
+
14
+ from .helpers import build_system_message, last_user_text
15
+
16
+
17
+ class VincContextMiddleware(AgentMiddleware):
18
+ """Adds Vinc context to the system message of each model call.
19
+
20
+ An agent that calls tools calls the model several times for one question.
21
+ The answer for a question is kept for ``cache_seconds`` so those calls do not
22
+ each spend a Vinc call. Nothing is written: this middleware only reads.
23
+ """
24
+
25
+ def __init__(self, client: VincClient | None = None,
26
+ async_client: AsyncVincClient | None = None, *, api_key: str | None = None,
27
+ base_url: str | None = None, space: str | None = None,
28
+ search_fallback: bool = True, max_chars: int = DEFAULT_MAX_CHARS,
29
+ max_excerpts: int = 3, cache_seconds: float = 60.0) -> None:
30
+ super().__init__()
31
+ self._config = {"api_key": api_key, "base_url": base_url, "space": space}
32
+ self._client = client
33
+ self._async_client = async_client
34
+ self.search_fallback = search_fallback
35
+ self.max_chars = max_chars
36
+ self.max_excerpts = max_excerpts
37
+ self.cache_seconds = cache_seconds
38
+ self._cache: dict[str, tuple[float, str | None]] = {}
39
+
40
+ # 클라이언트는 쓰는 쪽(동기·비동기)만 만든다 — 둘 다 만들면 연결 풀이 두 벌 생긴다.
41
+ @property
42
+ def client(self) -> VincClient:
43
+ if self._client is None:
44
+ self._client = VincClient(self._config["api_key"], base_url=self._config["base_url"],
45
+ space=self._config["space"])
46
+ return self._client
47
+
48
+ @property
49
+ def async_client(self) -> AsyncVincClient:
50
+ if self._async_client is None:
51
+ self._async_client = AsyncVincClient(self._config["api_key"],
52
+ base_url=self._config["base_url"],
53
+ space=self._config["space"])
54
+ return self._async_client
55
+
56
+ def _cached(self, query: str) -> tuple[bool, str | None]:
57
+ hit = self._cache.get(query)
58
+ if hit and time.monotonic() - hit[0] < self.cache_seconds:
59
+ return True, hit[1]
60
+ return False, None
61
+
62
+ def _remember(self, query: str, block: str | None) -> None:
63
+ if len(self._cache) >= 64:
64
+ self._cache.pop(next(iter(self._cache)))
65
+ self._cache[query] = (time.monotonic(), block)
66
+
67
+ def _with_block(self, request: ModelRequest, block: str | None) -> ModelRequest:
68
+ if not block:
69
+ return request
70
+ if hasattr(request, "system_message"):
71
+ return request.override(
72
+ system_message=build_system_message(block, request.system_message))
73
+ # langchain 1.0 초기판의 ModelRequest 는 문자열 system_prompt 만 갖는다.
74
+ base = getattr(request, "system_prompt", None) or ""
75
+ return request.override(system_prompt=f"{base}\n\n{block}" if base else block)
76
+
77
+ def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], Any]) -> Any:
78
+ query = last_user_text(request.messages)
79
+ found, block = self._cached(query)
80
+ if not found:
81
+ block = self.client.get_context(query, search_fallback=self.search_fallback,
82
+ max_chars=self.max_chars,
83
+ max_excerpts=self.max_excerpts)
84
+ self._remember(query, block)
85
+ return handler(self._with_block(request, block))
86
+
87
+ async def awrap_model_call(self, request: ModelRequest,
88
+ handler: Callable[[ModelRequest], Awaitable[Any]]) -> Any:
89
+ query = last_user_text(request.messages)
90
+ found, block = self._cached(query)
91
+ if not found:
92
+ block = await self.async_client.get_context(
93
+ query, search_fallback=self.search_fallback, max_chars=self.max_chars,
94
+ max_excerpts=self.max_excerpts)
95
+ self._remember(query, block)
96
+ return await handler(self._with_block(request, block))
File without changes
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib.util
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import pytest
12
+ from langchain_core.language_models.chat_models import BaseChatModel
13
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
14
+ from langchain_core.outputs import ChatGeneration, ChatResult
15
+ from langgraph.types import Command
16
+
17
+ from vinc_client import AsyncVincClient, VincAuthError, VincClient
18
+ from vinc_langgraph import (aget_vinc_context, build_system_message, create_vinc_tools,
19
+ get_vinc_context, last_user_text, record_episode)
20
+
21
+ RO_KEY = "vinc_ro_" + "a" * 32
22
+ SK_KEY = "vinc_sk_" + "b" * 32
23
+ RESOLVED = {
24
+ "status": "resolved",
25
+ "node": {"id": "decision:tokens-are-oklch", "title": "Colour tokens are stored as OKLCH"},
26
+ "one_liner": "Tokens keep OKLCH so contrast can be checked per theme.",
27
+ "grounds": ["decision:tokens-are-oklch"],
28
+ }
29
+ DOWN = (503, {"error": {"code": "upstream_unavailable", "message": "down"}})
30
+
31
+
32
+ class Routes:
33
+ def __init__(self, routes: dict[tuple[str, str], Any]):
34
+ self.routes = routes
35
+ self.seen: list[httpx.Request] = []
36
+
37
+ def __call__(self, request: httpx.Request) -> httpx.Response:
38
+ self.seen.append(request)
39
+ answer = self.routes.get((request.method, request.url.path))
40
+ if answer is None:
41
+ return httpx.Response(404, json={"error": {"code": "route_not_found", "message": ""}})
42
+ status, body = answer if isinstance(answer, tuple) else (200, answer)
43
+ return httpx.Response(status, json=body)
44
+
45
+ def paths(self) -> list[str]:
46
+ return [r.url.path for r in self.seen]
47
+
48
+
49
+ def sync_client(routes: Routes, key: str = RO_KEY) -> VincClient:
50
+ return VincClient(key, transport=httpx.MockTransport(routes))
51
+
52
+
53
+ def async_client(routes: Routes, key: str = RO_KEY) -> AsyncVincClient:
54
+ return AsyncVincClient(key, transport=httpx.MockTransport(routes))
55
+
56
+
57
+ class RecordingModel(BaseChatModel):
58
+ """Answers "ok" and keeps every message list it was given."""
59
+
60
+ calls: list = []
61
+
62
+ @property
63
+ def _llm_type(self) -> str:
64
+ return "recording"
65
+
66
+ def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
67
+ self.calls.append(list(messages))
68
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
69
+
70
+ def system_text(self) -> str:
71
+ first = self.calls[-1][0]
72
+ return first.text if isinstance(first, SystemMessage) else ""
73
+
74
+
75
+ # ── 헬퍼 ───────────────────────────────────────────────────────────────────────
76
+
77
+ def test_last_user_text_takes_the_newest_human_message():
78
+ msgs = [HumanMessage("first"), AIMessage("answer"), HumanMessage("the follow-up")]
79
+ assert last_user_text(msgs) == "the follow-up"
80
+ assert last_user_text(" plain ") == "plain"
81
+ assert last_user_text([AIMessage("only ai")]) == ""
82
+
83
+
84
+ def test_get_vinc_context_sync_and_async():
85
+ routes = Routes({("POST", "/v1/brief"): RESOLVED})
86
+ assert "decision:tokens-are-oklch" in get_vinc_context(sync_client(routes),
87
+ [HumanMessage("why oklch tokens")])
88
+ block = asyncio.run(aget_vinc_context(async_client(routes), "why oklch tokens"))
89
+ assert "decision:tokens-are-oklch" in block
90
+
91
+
92
+ def test_context_is_none_when_vinc_is_down():
93
+ routes = Routes({("POST", "/v1/brief"): DOWN})
94
+ assert get_vinc_context(sync_client(routes), "why oklch tokens") is None
95
+
96
+
97
+ def test_build_system_message_appends_and_keeps():
98
+ assert build_system_message(None) is None
99
+ base = SystemMessage(content="You review design changes.")
100
+ assert build_system_message(None, base) is base
101
+ fresh = build_system_message("BLOCK")
102
+ assert fresh.text == "BLOCK"
103
+ joined = build_system_message("BLOCK", "You review design changes.")
104
+ assert "You review design changes." in joined.text and "BLOCK" in joined.text
105
+
106
+
107
+ # ── 도구 ───────────────────────────────────────────────────────────────────────
108
+
109
+ def test_tools_are_read_only_and_work_sync_and_async():
110
+ routes = Routes({("POST", "/v1/brief"): RESOLVED,
111
+ ("POST", "/v1/search"): {"rows": [], "node_hits": [
112
+ {"id": "concept:spacing", "title": "Spacing", "kind": "concept"}]}})
113
+ tools = {t.name: t for t in create_vinc_tools(sync_client(routes), async_client(routes))}
114
+ assert sorted(tools) == ["vinc_brief", "vinc_search"]
115
+ assert "decision:tokens-are-oklch" in tools["vinc_brief"].invoke({"query": "tokens"})
116
+ out = asyncio.run(tools["vinc_search"].ainvoke({"query": "spacing", "limit": 2}))
117
+ assert "concept:spacing" in out and json.loads(routes.seen[-1].content)["limit"] == 2
118
+
119
+
120
+ def test_tools_degrade_to_a_sentence_when_vinc_is_down():
121
+ routes = Routes({("POST", "/v1/brief"): DOWN})
122
+ brief = {t.name: t for t in create_vinc_tools(sync_client(routes))}["vinc_brief"]
123
+ assert "not available" in brief.invoke({"query": "tokens"})
124
+
125
+
126
+ def test_tools_need_a_client():
127
+ with pytest.raises(ValueError):
128
+ create_vinc_tools()
129
+
130
+
131
+ # ── 쓰기 ───────────────────────────────────────────────────────────────────────
132
+
133
+ def test_record_episode_needs_a_write_key():
134
+ routes = Routes({})
135
+ with pytest.raises(VincAuthError):
136
+ record_episode(sync_client(routes), "Merged", about=["decision:tokens-are-oklch"])
137
+ assert routes.seen == []
138
+
139
+
140
+ # ── create_agent 미들웨어 ──────────────────────────────────────────────────────
141
+
142
+ def make_agent(middleware):
143
+ from langchain.agents import create_agent
144
+ model = RecordingModel()
145
+ model.calls = []
146
+ return create_agent(model, system_prompt="You review design changes.",
147
+ middleware=[middleware]), model
148
+
149
+
150
+ def test_middleware_puts_the_block_in_the_system_message():
151
+ from vinc_langgraph import VincContextMiddleware
152
+ routes = Routes({("POST", "/v1/brief"): RESOLVED})
153
+ agent, model = make_agent(VincContextMiddleware(sync_client(routes)))
154
+ out = agent.invoke({"messages": [HumanMessage("Why are colour tokens OKLCH?")]})
155
+ assert out["messages"][-1].content == "ok"
156
+ system = model.system_text()
157
+ assert "You review design changes." in system and "decision:tokens-are-oklch" in system
158
+ assert routes.paths() == ["/v1/brief"]
159
+
160
+
161
+ def test_middleware_reuses_the_answer_for_the_same_question():
162
+ from vinc_langgraph import VincContextMiddleware
163
+ routes = Routes({("POST", "/v1/brief"): RESOLVED})
164
+ agent, _ = make_agent(VincContextMiddleware(sync_client(routes)))
165
+ for _ in range(2):
166
+ agent.invoke({"messages": [HumanMessage("Why are colour tokens OKLCH?")]})
167
+ assert routes.paths() == ["/v1/brief"]
168
+
169
+
170
+ def test_middleware_async_path_and_vinc_down():
171
+ from vinc_langgraph import VincContextMiddleware
172
+ routes = Routes({("POST", "/v1/brief"): DOWN})
173
+ agent, model = make_agent(VincContextMiddleware(async_client=async_client(routes)))
174
+ out = asyncio.run(agent.ainvoke({"messages": [HumanMessage("why are tokens oklch")]}))
175
+ assert out["messages"][-1].content == "ok"
176
+ assert "DATA" not in model.system_text()
177
+
178
+
179
+ # ── 예제 그래프: interrupt 로 멈추고, 승인한 것만 쓴다 ─────────────────────────
180
+
181
+ def load_example():
182
+ path = Path(__file__).resolve().parents[1] / "examples" / "approve_then_record.py"
183
+ spec = importlib.util.spec_from_file_location("approve_then_record", path)
184
+ module = importlib.util.module_from_spec(spec)
185
+ # 상태 TypedDict 의 주석을 LangGraph 가 모듈 전역으로 푼다 — 등록 안 하면 못 찾는다.
186
+ sys.modules[spec.name] = module
187
+ spec.loader.exec_module(module)
188
+ return module
189
+
190
+
191
+ @pytest.mark.parametrize("approved", [True, False])
192
+ def test_example_graph_writes_only_after_approval(approved):
193
+ reads = Routes({("POST", "/v1/brief"): RESOLVED})
194
+ writes = Routes({("GET", "/v1/nodes/decision:tokens-are-oklch"): RESOLVED,
195
+ ("POST", "/v1/fragments"): {"warnings": [], "stored": []}})
196
+ app = load_example().build_graph(sync_client(reads), sync_client(writes, SK_KEY))
197
+ config = {"configurable": {"thread_id": f"t-{approved}"}}
198
+ paused = app.invoke({"question": "Does the secondary button meet contrast on dark?",
199
+ "about": ["decision:tokens-are-oklch"]}, config)
200
+ assert "__interrupt__" in paused and writes.seen == []
201
+ done = app.invoke(Command(resume=approved), config)
202
+ if approved:
203
+ assert writes.paths() == ["/v1/nodes/decision:tokens-are-oklch", "/v1/fragments"]
204
+ assert done["written"] is not None
205
+ else:
206
+ assert writes.seen == [] and done["written"] is None
207
+ assert reads.paths() == ["/v1/brief"]