From 890d13c98df7e07106e75da7aca81efb01aeb7cb Mon Sep 17 00:00:00 2001 From: Milix-M <70957923+Milix-M@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:20:22 +0900 Subject: [PATCH 01/55] =?UTF-8?q?pytest=E9=96=A2=E9=80=A3=E3=83=91?= =?UTF-8?q?=E3=83=83=E3=82=B1=E3=83=BC=E3=82=B8=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 3 ++ uv.lock | 136 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6013df1..b896b54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ dependencies = [ "langchain-openai>=1.0.1", "langgraph>=1.0.1", "nest-asyncio>=1.6.0", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", "streamlit>=1.51.0", "uvicorn[standard]>=0.38.0", "websockets>=15.0.1", diff --git a/uv.lock b/uv.lock index c2b54ee..1970eea 100644 --- a/uv.lock +++ b/uv.lock @@ -229,6 +229,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +] + [[package]] name = "ddgs" version = "9.6.1" @@ -256,6 +317,9 @@ dependencies = [ { name = "langchain-openai" }, { name = "langgraph" }, { name = "nest-asyncio" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "streamlit" }, { name = "uvicorn", extra = ["standard"] }, { name = "websockets" }, @@ -270,6 +334,9 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=1.0.1" }, { name = "langgraph", specifier = ">=1.0.1" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "streamlit", specifier = ">=1.51.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, { name = "websockets", specifier = ">=15.0.1" }, @@ -445,6 +512,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1035,6 +1111,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "primp" version = "0.15.0" @@ -1174,6 +1259,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From c7fbecfcec979ca00f587a9b2b25cf35c04d153b Mon Sep 17 00:00:00 2001 From: Milix-M <70957923+Milix-M@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:20:40 +0900 Subject: [PATCH 02/55] =?UTF-8?q?test=E3=82=B3=E3=83=BC=E3=83=89=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/ai/analyze/test_query_analyze.py | 55 +++ .../ai/reflect/test_reflect_search_result.py | 61 +++ .../backend/ai/schedule/test_plan_reserch.py | 46 ++ tests/backend/api/test_main.py | 234 ++++++++++ tests/backend/api/test_schemas.py | 37 ++ tests/backend/test_agent.py | 398 ++++++++++++++++++ tests/backend/test_workflow_service.py | 357 ++++++++++++++++ tests/backend/tools/test_get_current_date.py | 13 + tests/backend/tools/test_search_reflect.py | 47 +++ tests/backend/tools/test_web_research.py | 35 ++ 10 files changed, 1283 insertions(+) create mode 100644 tests/backend/ai/analyze/test_query_analyze.py create mode 100644 tests/backend/ai/reflect/test_reflect_search_result.py create mode 100644 tests/backend/ai/schedule/test_plan_reserch.py create mode 100644 tests/backend/api/test_main.py create mode 100644 tests/backend/api/test_schemas.py create mode 100644 tests/backend/test_agent.py create mode 100644 tests/backend/test_workflow_service.py create mode 100644 tests/backend/tools/test_get_current_date.py create mode 100644 tests/backend/tools/test_search_reflect.py create mode 100644 tests/backend/tools/test_web_research.py diff --git a/tests/backend/ai/analyze/test_query_analyze.py b/tests/backend/ai/analyze/test_query_analyze.py new file mode 100644 index 0000000..de91968 --- /dev/null +++ b/tests/backend/ai/analyze/test_query_analyze.py @@ -0,0 +1,55 @@ +import pytest + +from src.backend.ai.analyze.query_analyze import (QueryAnalyzeAI, + ResearchParameters) + + +class DummyStructured: + def __init__(self, schema): + self.schema = schema + self.invocations: list = [] + + async def ainvoke(self, prompt): + self.invocations.append(prompt) + assert prompt[0][0] == "system" + return self.schema( + search_queries_per_section=2, + search_iterations=3, + reasoning="reason", + ) + + +class DummyLLM: + def __init__(self): + self.structured_schema = None + + def with_structured_output(self, schema): + self.structured_schema = schema + return DummyStructured(schema) + + +@pytest.mark.asyncio +async def test_query_analyze_ai_invokes_llm(): + llm = DummyLLM() + ai = QueryAnalyzeAI(llm) + params = await ai("質問") + + assert isinstance(params, ResearchParameters) + assert llm.structured_schema is ResearchParameters + assert params.search_queries_per_section == 2 + + +def test_research_parameters_validation(): + with pytest.raises(ValueError): + ResearchParameters( + search_queries_per_section=0, + search_iterations=1, + reasoning="x", + ) + + instance = ResearchParameters( + search_queries_per_section=1, + search_iterations=5, + reasoning="ok", + ) + assert instance.is_lc_serializable() diff --git a/tests/backend/ai/reflect/test_reflect_search_result.py b/tests/backend/ai/reflect/test_reflect_search_result.py new file mode 100644 index 0000000..7338734 --- /dev/null +++ b/tests/backend/ai/reflect/test_reflect_search_result.py @@ -0,0 +1,61 @@ +from src.backend.ai.reflect import reflect_search_result as module +from src.backend.ai.reflect.reflect_search_result import ( + ImprovedQuery, + KeyInsight, + ReflectionResultSchema, + SearchResultAnalyzeAndReflectAI, +) + + +class DummyStructured: + def __init__(self, schema): + self.schema = schema + self.calls = [] + + def invoke(self, prompt): + self.calls.append(prompt) + return schema_instance() + + +def schema_instance() -> ReflectionResultSchema: + return ReflectionResultSchema( + key_insights=[ + KeyInsight(insight="情報", confidence=7, source_indication="link"), + ], + information_gaps=["gap"], + contradictions=["contradiction"], + improved_queries=[ + ImprovedQuery(query="better", rationale="because"), + ], + summary="summary", + ) + + +class DummyLLM: + def __init__(self): + self.schema = None + + def with_structured_output(self, schema): + self.schema = schema + return DummyStructured(schema) + + +def test_structures_are_lc_serializable(): + assert KeyInsight.is_lc_serializable() + assert ImprovedQuery.is_lc_serializable() + assert ReflectionResultSchema.is_lc_serializable() + + +def test_reflection_ai_invokes_structured_llm(monkeypatch): + llm = DummyLLM() + monkeypatch.setattr( + module, + "SEARCH_RESULT_ANALYZE_AND_REFLECTION_SYSTEM_PROMPT", + "{result}", + ) + ai = SearchResultAnalyzeAndReflectAI(llm) + result = ai("query", "result") + + assert isinstance(result, ReflectionResultSchema) + assert llm.schema is ReflectionResultSchema + assert result.key_insights[0].confidence == 7 diff --git a/tests/backend/ai/schedule/test_plan_reserch.py b/tests/backend/ai/schedule/test_plan_reserch.py new file mode 100644 index 0000000..8d79ff5 --- /dev/null +++ b/tests/backend/ai/schedule/test_plan_reserch.py @@ -0,0 +1,46 @@ +import pytest + +from src.backend.ai.schedule.plan_reserch import (GeneratedObjectSchema, + PlanResearchAI, ResearchPlan, + Section, Structure) + + +class DummyStructured: + def __init__(self, schema): + self.schema = schema + + async def ainvoke(self, prompt): + assert prompt[0][0] == "system" + plan = ResearchPlan( + purpose="purpose", + sections=[Section(title="t", focus="f", key_questions=["q"])], + structure=Structure(introduction="intro", conclusion="outro"), + ) + return GeneratedObjectSchema(research_plan=plan, meta_analysis="meta") + + +class DummyLLM: + def __init__(self): + self.schema = None + + def with_structured_output(self, schema): + self.schema = schema + return DummyStructured(schema) + + +@pytest.mark.asyncio +async def test_plan_research_ai_invocation(): + llm = DummyLLM() + ai = PlanResearchAI(llm) + result = await ai("題材") + + assert isinstance(result, GeneratedObjectSchema) + assert llm.schema is GeneratedObjectSchema + assert result.research_plan.sections[0].title == "t" + + +def test_section_serialization_flags(): + assert Section.is_lc_serializable() + assert Structure.is_lc_serializable() + assert ResearchPlan.is_lc_serializable() + assert GeneratedObjectSchema.is_lc_serializable() diff --git a/tests/backend/api/test_main.py b/tests/backend/api/test_main.py new file mode 100644 index 0000000..77672a9 --- /dev/null +++ b/tests/backend/api/test_main.py @@ -0,0 +1,234 @@ +import asyncio +import os +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +os.environ.setdefault("OPENAI_API_KEY", "test") +os.environ.setdefault("OPENROUTER_API_KEY", "test") +os.environ.setdefault("ANTHROPIC_API_KEY", "test") +os.environ.setdefault("GOOGLE_API_KEY", "test") + +from src.backend.api import main +from src.backend.api.workflow import StateNotFoundError + + +class StubWorkflowService: + def __init__(self): + self.outcomes: list[SimpleNamespace] = [] + self.resume_calls: list[tuple[str, str, object | None]] = [] + self.start_calls: list[tuple[str, str]] = [] + + def diagnostics(self): + return {"ok": True} + + def list_active_threads(self): + return ["thread-1"] + + def list_pending_interrupts(self): + return ["thread-1"] + + def create_thread_id(self): + return "thread-generated" + + async def start_research(self, *, thread_id: str, query: str): + self.start_calls.append((thread_id, query)) + return self.outcomes.pop(0) + + async def resume_research(self, *, thread_id: str, decision: str, plan_update): + self.resume_calls.append((thread_id, decision, plan_update)) + return self.outcomes.pop(0) + + def get_state(self, thread_id: str): + if thread_id == "missing": + raise StateNotFoundError("not found") + return SimpleNamespace( + status="pending_human", + state={"value": 1}, + pending_interrupt={"id": "i", "value": "v"}, + ) + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch): + service = StubWorkflowService() + monkeypatch.setattr(main, "workflow_service", service) + return TestClient(main.app), service + + +def test_resolve_allowed_origins(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("CORS_ALLOW_ORIGINS", "https://a, https://b") + assert main._resolve_allowed_origins() == ["https://a", "https://b"] + + monkeypatch.delenv("CORS_ALLOW_ORIGINS", raising=False) + defaults = set(main._resolve_allowed_origins()) + assert "http://localhost:3000" in defaults + + +@pytest.mark.asyncio +async def test_send_ws_events(): + class StubWebSocket: + def __init__(self): + self.sent = [] + + async def send_json(self, payload): + self.sent.append(payload) + + websocket = StubWebSocket() + events = [{"event": "alpha"}, {"event": "beta"}] + await main._send_ws_events(websocket, "thread", events) # type: ignore[arg-type] + + assert websocket.sent[0]["thread_id"] == "thread" + assert websocket.sent[1]["payload"]["event"] == "beta" + + +def test_interrupt_from_raw(): + payload = main._interrupt_from_raw({"id": "x", "value": "y"}) + assert payload is not None + assert payload.id == "x" + assert main._interrupt_from_raw(None) is None + + +def test_healthcheck_and_lists(client): + test_client, service = client + response = test_client.get("/healthz") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + response = test_client.get("/threads") + body = response.json() + assert body["active_count"] == 1 + assert service.start_calls == [] + + +def test_get_thread_state(client): + test_client, _ = client + response = test_client.get("/threads/thread-1/state") + assert response.status_code == 200 + assert response.json()["pending_interrupt"]["id"] == "i" + + response = test_client.get("/threads/missing/state") + assert response.status_code == 404 + + +def test_websocket_research_flow(client): + test_client, service = client + + service.outcomes = [ + SimpleNamespace( + status="pending_human", + events=[{"event": "message", "payload": 1}], + state={"stage": 1}, + interrupt={"id": "plan", "value": "prompt"}, + ), + SimpleNamespace( + status="completed", + events=[{"event": "message", "payload": 2}], + state={"stage": 2}, + interrupt=None, + ), + ] + + with test_client.websocket_connect("/ws/research") as ws: + ws.send_json({"query": " run "}) + start = ws.receive_json() + assert start["type"] == "thread_started" + + event_frame = ws.receive_json() + assert event_frame["payload"]["event"] == "message" + + interrupt = ws.receive_json() + assert interrupt["type"] == "interrupt" + + ws.send_json({"decision": "maybe"}) + error = ws.receive_json() + assert error["type"] == "error" + + interrupt_again = ws.receive_json() + assert interrupt_again["type"] == "interrupt" + + ws.send_json({"decision": "y", "plan": {"updated": True}}) + event_after_resume = ws.receive_json() + assert event_after_resume["type"] == "event" + assert event_after_resume["payload"]["event"] == "message" + + final = ws.receive_json() + assert final["type"] == "complete" + + assert service.resume_calls == [("thread-generated", "y", {"updated": True})] + + +def test_websocket_research_blank_query(client): + test_client, _ = client + with test_client.websocket_connect("/ws/research") as ws: + ws.send_json({"query": " "}) + error = ws.receive_json() + assert error["type"] == "error" + with pytest.raises(WebSocketDisconnect) as exc: + ws.receive_json() + assert exc.value.code == 4000 + + +def test_websocket_missing_interrupt(client): + test_client, service = client + service.outcomes = [ + SimpleNamespace( + status="pending_human", + events=[], + state={"stage": 0}, + interrupt=None, + ) + ] + + with test_client.websocket_connect("/ws/research") as ws: + ws.send_json({"query": "fail"}) + ws.receive_json() + error = ws.receive_json() + assert error["type"] == "error" + with pytest.raises(WebSocketDisconnect) as exc: + ws.receive_json() + assert exc.value.code == 1011 + + +@pytest.mark.asyncio +async def test_websocket_research_finally_closes(monkeypatch: pytest.MonkeyPatch): + class ExplodingService: + def create_thread_id(self): + return "tid" + + async def start_research(self, *, thread_id: str, query: str): + raise RuntimeError("boom") + + class StubWebSocket: + def __init__(self): + self.sent = [] + self.closed: list[int] = [] + self.inputs = iter([{"query": "anything"}]) + + async def accept(self): + return None + + async def receive_json(self): + return next(self.inputs) + + async def send_json(self, payload): + self.sent.append(payload) + + async def close(self, code: int): + self.closed.append(code) + + @property + def application_state(self): + from starlette.websockets import WebSocketState + + return WebSocketState.CONNECTED + + monkeypatch.setattr(main, "workflow_service", ExplodingService()) + stub = StubWebSocket() + + await main.websocket_research(stub) # type: ignore[arg-type] + + assert stub.sent[-1]["type"] == "error" + assert stub.closed.count(1000) >= 1 diff --git a/tests/backend/api/test_schemas.py b/tests/backend/api/test_schemas.py new file mode 100644 index 0000000..91c82ac --- /dev/null +++ b/tests/backend/api/test_schemas.py @@ -0,0 +1,37 @@ +from datetime import datetime, timezone + +from src.backend.api import schemas + + +def test_interrupt_payload_roundtrip(): + payload = schemas.InterruptPayload(id="x", value={"foo": 1}) + assert payload.model_dump()["id"] == "x" + + +def test_health_response_structure(): + response = schemas.HealthResponse( + status="ok", + timestamp=datetime.now(timezone.utc), + details={"threads": 1}, + ) + assert response.status == "ok" + + +def test_thread_list_response_counts(): + listing = schemas.ThreadListResponse( + active_thread_ids=["a"], + pending_interrupt_ids=[], + active_count=1, + pending_count=0, + ) + assert listing.active_count == len(listing.active_thread_ids) + + +def test_state_response_includes_optional_interrupt(): + payload = schemas.StateResponse( + thread_id="t", + status="done", + state={"value": 1}, + pending_interrupt=None, + ) + assert payload.thread_id == "t" diff --git a/tests/backend/test_agent.py b/tests/backend/test_agent.py new file mode 100644 index 0000000..6033484 --- /dev/null +++ b/tests/backend/test_agent.py @@ -0,0 +1,398 @@ +import types + +import pytest +from langchain_core.runnables import RunnableConfig + +from src.backend import agent as agent_module +from src.backend.agent import ( + GeneratedObjectSchema, + NamespaceAwareJsonPlusSerializer, + OSSDeepResearchAgent, + ResearchParameters, + State, +) + + +class DummyChatOpenAI: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.bound_tools = None + + def with_structured_output(self, schema): + return types.SimpleNamespace(schema=schema) + + def bind_tools(self, tools): + self.bound_tools = tuple(tools) + return DummyToolLLM() + + +class DummyToolLLM: + def __init__(self): + self.calls = [] + self.responses: list = [] + + async def ainvoke(self, messages): + self.calls.append(messages) + if self.responses: + return self.responses.pop(0) + return types.SimpleNamespace(tool_calls=[], content="done") + + +@pytest.fixture(autouse=True) +def patch_chat_open_ai(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(agent_module, "ChatOpenAI", DummyChatOpenAI) + + +def test_namespace_serializer_uses_custom_reviver(monkeypatch: pytest.MonkeyPatch): + serializer = NamespaceAwareJsonPlusSerializer(valid_namespaces=["src"]) + + captured = {} + + def fake_reviver(value): + captured["value"] = value + return {"ok": True} + + serializer._reviver_with_ns = fake_reviver # type: ignore[attr-defined] + + assert serializer._reviver({"foo": "bar"}) == {"ok": True} + assert captured["value"] == {"foo": "bar"} + + serializer._allowed_modules = ["mock"] # type: ignore[attr-defined] + + def fake_revive_lc2(value): + raise agent_module.InvalidModuleError("bad") + + serializer._revive_lc2 = fake_revive_lc2 # type: ignore[attr-defined] + serializer._reviver({"lc": 2, "type": "constructor", "id": "bad"}) + assert captured["value"]["id"] == "bad" + + +def test_state_validator_restores_legacy_instances(): + class LegacyParams: + def model_dump(self): + return { + "search_queries_per_section": 2, + "search_iterations": 3, + "reasoning": "legacy", + } + + class LegacyPlan: + def model_dump(self): + return { + "research_plan": { + "purpose": "reason", + "sections": [ + { + "title": "s1", + "focus": "focus", + "key_questions": ["q1"], + } + ], + "structure": { + "introduction": "intro", + "conclusion": "outro", + }, + }, + "meta_analysis": "analysis", + } + + state = State.model_validate( + { + "user_input": "topic", + "research_parameters": LegacyParams(), + "research_plan": LegacyPlan(), + } + ) + + assert isinstance(state.research_parameters, ResearchParameters) + assert isinstance(state.research_plan, GeneratedObjectSchema) + + dict_state = State.model_validate( + { + "user_input": "topic", + "research_parameters": { + "search_queries_per_section": 1, + "search_iterations": 1, + "reasoning": "dict", + }, + "research_plan": LegacyPlan().model_dump(), + } + ) + assert isinstance(dict_state.research_parameters, ResearchParameters) + assert isinstance(dict_state.research_plan, GeneratedObjectSchema) + + +def test_agent_initialization_applies_nest_asyncio(monkeypatch: pytest.MonkeyPatch): + def raise_runtime_error(*_args, **_kwargs): + raise RuntimeError + + applied = {} + + def fake_apply(loop): + applied["loop"] = loop + raise ValueError("already patched") + + monkeypatch.setattr(agent_module.asyncio, "get_running_loop", raise_runtime_error) + monkeypatch.setattr(agent_module.asyncio, "get_event_loop", raise_runtime_error) + monkeypatch.setattr(agent_module.nest_asyncio, "apply", fake_apply) + + agent = OSSDeepResearchAgent() + + assert applied["loop"] is None + assert len(agent.tools) == 3 + assert isinstance(agent.tool_callable_llm, DummyChatOpenAI) + assert isinstance(agent.llm_with_tools, DummyToolLLM) + + +@pytest.mark.asyncio +async def test_agent_nodes_and_routing(monkeypatch: pytest.MonkeyPatch): + generated_params = ResearchParameters( + search_queries_per_section=1, + search_iterations=2, + reasoning="ok", + ) + + plan_payload = { + "research_plan": { + "purpose": "goal", + "sections": [ + { + "title": "t", + "focus": "f", + "key_questions": ["q"], + } + ], + "structure": { + "introduction": "intro", + "conclusion": "outro", + }, + }, + "meta_analysis": "meta", + } + generated_plan = GeneratedObjectSchema.model_validate(plan_payload) + + class DummyQueryAnalyzeAI: + def __init__(self, llm): + self.llm = llm + self.calls: list = [] + + async def __call__(self, query: str): + self.calls.append(query) + return generated_params + + class DummyPlanResearchAI: + def __init__(self, llm): + self.llm = llm + self.calls: list = [] + + async def __call__(self, query: str): + self.calls.append(query) + return generated_plan + + dummy_tool_llm = DummyToolLLM() + dummy_tool_llm.responses = [ + types.SimpleNamespace(tool_calls=[{"tool": "call"}], content="tool"), + types.SimpleNamespace( + tool_calls=[], + content=[ + {"text": "fragment"}, + {"type": "text", "text": "more"}, + {"other": 1}, + "tail", + ], + ), + ] + + monkeypatch.setattr(agent_module, "QueryAnalyzeAI", DummyQueryAnalyzeAI) + monkeypatch.setattr(agent_module, "PlanResearchAI", DummyPlanResearchAI) + + agent = OSSDeepResearchAgent() + agent.llm_with_tools = dummy_tool_llm # type: ignore[assignment] + + state = State( + user_input="topic", + research_parameters=None, + research_plan=None, + ) + + result_params = await agent._node_generate_research_parameters( + state, RunnableConfig() + ) + assert result_params["research_parameters"] is generated_params + + state.research_parameters = generated_params + result_plan = await agent._node_make_research_plan(state, RunnableConfig()) + assert result_plan["research_plan"] == generated_plan + + state.research_plan = generated_plan + prepared = agent._node_prepare_research(state) + assert len(prepared["messages"]) == 2 + state.messages.extend(prepared["messages"]) + + monkeypatch.setattr(agent_module, "interrupt", lambda _prompt: "y") + await agent._research_plan_human_judge(state, RunnableConfig()) + assert state.research_plan_human_edit is True + + monkeypatch.setattr(agent_module, "interrupt", lambda _prompt: "n") + await agent._research_plan_human_judge(state, RunnableConfig()) + assert state.research_plan_human_edit is False + + patched_plan = agent._node_edit_research_plan( + State( + research_plan=GeneratedObjectSchema.model_validate(plan_payload), + user_input="topic", + ) + ) + assert isinstance(patched_plan["research_plan"], GeneratedObjectSchema) + + class LegacyPlanWrapper: + def model_dump(self): + return plan_payload + + wrapped_state = State.model_validate( + { + "user_input": "topic", + "research_parameters": generated_params, + "research_plan": LegacyPlanWrapper(), + } + ) + wrapped_plan = agent._node_edit_research_plan(wrapped_state) + assert isinstance(wrapped_plan["research_plan"], GeneratedObjectSchema) + + dict_state = State.model_validate( + { + "user_input": "topic", + "research_parameters": generated_params, + "research_plan": plan_payload, + } + ) + dict_plan = agent._node_edit_research_plan(dict_state) + assert isinstance(dict_plan["research_plan"], GeneratedObjectSchema) + raw_state = State.model_construct( + user_input="topic", + research_parameters=generated_params, + research_plan=LegacyPlanWrapper(), + ) + raw_plan = agent._node_edit_research_plan(raw_state) + assert isinstance(raw_plan["research_plan"], GeneratedObjectSchema) + + raw_dict_state = State.model_construct( + user_input="topic", + research_parameters=generated_params, + research_plan=plan_payload, + ) + raw_dict_plan = agent._node_edit_research_plan(raw_dict_state) + assert isinstance(raw_dict_plan["research_plan"], GeneratedObjectSchema) + assert agent._node_edit_research_plan(State(user_input="topic")) == {} + + loop_state = State( + user_input="topic", + research_parameters=generated_params, + research_plan=generated_plan, + messages=list(state.messages), + ) + loop_state.messages.extend( + (await agent._node_deep_research(loop_state, RunnableConfig()))["messages"] + ) + assert agent._routing_should_continue(loop_state) == "continue_react_loop" + + loop_state.messages.extend( + (await agent._node_deep_research(loop_state, RunnableConfig()))["messages"] + ) + assert agent._routing_should_continue(loop_state) == "finish_research" + + assert ( + agent._routing_human_edit_judge( + State(user_input="q", research_plan_human_edit=True) + ) + == "edit" + ) + assert ( + agent._routing_human_edit_judge( + State(user_input="q", research_plan_human_edit=False) + ) + == "search" + ) + + summary = agent._node_write_research_result(loop_state) + report = summary.get("report") or "" + assert "fragment" in report + + string_summary = agent._node_write_research_result( + State(messages=[types.SimpleNamespace(content="final")], user_input="q") + ) + assert string_summary["report"] == "final" + + value_summary = agent._node_write_research_result( + State( + messages=[ + types.SimpleNamespace(content=[{"type": "text", "value": "alt"}]) + ], + user_input="q", + ) + ) + alt_report = value_summary.get("report") or "" + assert "alt" in alt_report + + +def test_agent_skips_nest_asyncio_on_uvloop(monkeypatch: pytest.MonkeyPatch): + class UVLoop: + __module__ = "uvloop.loop" + + monkeypatch.setattr(agent_module.asyncio, "get_running_loop", lambda: UVLoop()) + called = [] + + def fake_apply(loop): + called.append(loop) + + monkeypatch.setattr(agent_module.nest_asyncio, "apply", fake_apply) + + agent = OSSDeepResearchAgent() + assert called == [] + assert isinstance(agent.llm_with_tools, DummyToolLLM) + + +def test_get_compiled_graph_uses_custom_serializer(monkeypatch: pytest.MonkeyPatch): + recorded = {} + + class DummyStateGraph: + def __init__(self, state_cls): + recorded["state_cls"] = state_cls + self.nodes = [] + self.edges = [] + + def add_node(self, name_or_callable, maybe_fn=None): + if maybe_fn is None: + self.nodes.append(name_or_callable) + else: + self.nodes.append((name_or_callable, maybe_fn)) + + def add_edge(self, source, target): + self.edges.append((source, target)) + + def add_conditional_edges(self, node, router, mapping): + self.edges.append((node, tuple(mapping.items()))) + + def compile(self, checkpointer): + recorded["checkpointer"] = checkpointer + return "compiled" + + class DummyToolNode: + def __init__(self, tools): + recorded["tools"] = tuple(tools) + + class DummyMemorySaver: + def __init__(self, serde): + recorded["serde"] = serde + + monkeypatch.setattr(agent_module, "StateGraph", DummyStateGraph) + monkeypatch.setattr(agent_module, "ToolNode", DummyToolNode) + monkeypatch.setattr(agent_module, "MemorySaver", DummyMemorySaver) + + agent = OSSDeepResearchAgent() + compiled = agent.get_compiled_graph() + + assert compiled == "compiled" + assert recorded["state_cls"] is State + assert isinstance(recorded["serde"], NamespaceAwareJsonPlusSerializer) + assert len(recorded["tools"]) == 3 diff --git a/tests/backend/test_workflow_service.py b/tests/backend/test_workflow_service.py new file mode 100644 index 0000000..f72a4bd --- /dev/null +++ b/tests/backend/test_workflow_service.py @@ -0,0 +1,357 @@ +import os +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, Dict, List, NamedTuple + +import pytest + +os.environ.setdefault("OPENAI_API_KEY", "test-key") + +from src.backend.api import workflow + + +class DummyModel: + def __init__(self, label: str) -> None: + self.label = label + + def model_dump(self) -> Dict[str, Any]: + return {"label": self.label, "nested": {"value": 42}} + + +class SimpleInterrupt: + def __init__(self, interrupt_id: str, value: str) -> None: + self.id = interrupt_id + self.value = value + + +@dataclass +class DummySnapshot: + values: Dict[str, Any] + next: Any | None + + +class ScriptStep(NamedTuple): + events: List[Any] + state: DummySnapshot + + +class DummyGraph: + """LangGraph 互換の最小実装。""" + + def __init__(self) -> None: + self.scripts: Dict[str, List[ScriptStep]] = {} + self.indices: defaultdict[str, int] = defaultdict(int) + self.states: Dict[str, DummySnapshot] = {} + + def set_script(self, thread_id: str, steps: List[ScriptStep]) -> None: + self.scripts[thread_id] = steps + self.indices[thread_id] = 0 + + async def astream_events( + self, payload: Any, *, config: Dict[str, Any], version: str + ) -> Any: + thread_id: str = config["configurable"]["thread_id"] + step_index = self.indices[thread_id] + script = self.scripts[thread_id][step_index] + self.states[thread_id] = script.state + try: + for event in script.events: + yield event + finally: + self.indices[thread_id] += 1 + + def get_state(self, config: Dict[str, Any]) -> DummySnapshot | None: + thread_id: str = config["configurable"]["thread_id"] + return self.states.get(thread_id) + + +@pytest.fixture() +def service(monkeypatch: pytest.MonkeyPatch): + graph = DummyGraph() + + class StubAgent: + def __init__(self) -> None: + self.graph = graph + + def get_compiled_graph(self) -> DummyGraph: + return self.graph + + monkeypatch.setattr(workflow, "OSSDeepResearchAgent", StubAgent) + monkeypatch.setattr(workflow, "Interrupt", SimpleInterrupt) + monkeypatch.setenv("GRAPH_RECURSION_LIMIT", "7") + + svc = workflow.WorkflowService() + + prompt_interrupt = SimpleInterrupt( + "plan_judge", "調査計画を編集しますか? y or n: " + ) + model = DummyModel("alpha") + graph.set_script( + "thread-1", + [ + ScriptStep( + events=[ + {"event": "message", "data": "warmup"}, + { + "event": "on_chain_stream", + "data": { + "chunk": { + "__interrupt__": [prompt_interrupt], + "model": model, + } + }, + }, + ], + state=DummySnapshot(values={"step": model}, next=["continue"]), + ), + ScriptStep( + events=[ + { + "event": "on_chain_end", + "data": {"output": {"__interrupt__": []}}, + }, + ], + state=DummySnapshot(values={"report": "done"}, next=None), + ), + ], + ) + + auto_interrupt = SimpleInterrupt("auto", "別の質問を検討しますか?") + graph.set_script( + "auto-thread", + [ + ScriptStep( + events=[ + { + "event": "on_chain_stream", + "data": {"chunk": {"__interrupt__": [auto_interrupt]}}, + } + ], + state=DummySnapshot(values={"stage": "auto"}, next=["next"]), + ), + ScriptStep( + events=[{"event": "on_chain_end", "data": {"output": {}}}], + state=DummySnapshot(values={"stage": "auto-done"}, next=None), + ), + ], + ) + + graph.set_script( + "sse-thread", + [ + ScriptStep( + events=[{"event": "info", "data": {"note": "first"}}], + state=DummySnapshot(values={"progress": 1}, next=None), + ), + ], + ) + + return svc, graph + + +@pytest.mark.asyncio +async def test_start_research_returns_interrupt(service): + svc, _ = service + outcome = await svc.start_research(thread_id="thread-1", query="test") + assert outcome.status == "pending_human" + assert outcome.interrupt == { + "id": "plan_judge", + "value": "調査計画を編集しますか? y or n: ", + } + assert [event["event"] for event in outcome.events] == [ + "message", + "on_chain_stream", + ] + assert svc.list_active_threads() == ["thread-1"] + assert svc.list_pending_interrupts() == ["thread-1"] + + snapshot = svc.get_state("thread-1") + assert snapshot.status == "pending_human" + assert snapshot.state == {"step": {"label": "alpha", "nested": {"value": 42}}} + assert snapshot.pending_interrupt == { + "id": "plan_judge", + "value": "調査計画を編集しますか? y or n: ", + } + + +@pytest.mark.asyncio +async def test_resume_research_completes(service): + svc, _ = service + await svc.start_research(thread_id="thread-1", query="test") + outcome = await svc.resume_research( + thread_id="thread-1", decision="y", plan_update={"updated": True} + ) + + assert outcome.status == "completed" + assert outcome.interrupt is None + assert svc.list_active_threads() == [] + assert svc.list_pending_interrupts() == [] + + snapshot = svc.get_state("thread-1") + assert snapshot.status == "completed" + assert snapshot.state == {"report": "done"} + + +@pytest.mark.asyncio +async def test_resume_requires_hitl(service): + svc, _ = service + with pytest.raises(workflow.HitlNotEnabledError): + await svc.resume_research(thread_id="unknown", decision="n", plan_update=None) + + +@pytest.mark.asyncio +async def test_resume_requires_pending_interrupt(service): + svc, _ = service + svc._hitl_threads.add("no-pending") + with pytest.raises(workflow.InterruptNotFoundError): + await svc.resume_research( + thread_id="no-pending", decision="n", plan_update=None + ) + + +def test_get_state_not_found(service): + svc, graph = service + graph.states.pop("missing", None) + with pytest.raises(workflow.StateNotFoundError): + svc.get_state("missing") + + +@pytest.mark.asyncio +async def test_stream_events(service): + svc, _ = service + frames = [] + async for frame in svc.stream_events( + thread_id="sse-thread", query="anything", auto_resume=False + ): + frames.append(frame) + + assert frames[0].startswith("event: info") + assert "state_snapshot" in frames[-1] + + +def test_render_event(service): + svc, _ = service + rendered = svc.render_event({"event": "custom", "data": {"value": 1}}) + assert rendered.startswith("event: custom") + assert rendered.endswith("\n\n") + + +@pytest.mark.asyncio +async def test_auto_resume_flow(service): + svc, _ = service + outcome = await svc.start_research(thread_id="auto-thread", query="auto") + assert outcome.status == "completed" + assert any(event["event"] == "auto_resume" for event in outcome.events) + assert outcome.interrupt is None + + +def test_internal_helpers(service): + svc, graph = service + svc._hitl_threads.clear() + graph.states["running-thread"] = DummySnapshot(values={"foo": 1}, next=["step"]) + state = svc.get_state("running-thread") + assert state.status == "running" + + assert svc._serialize_interrupt(None) is None + interrupt = SimpleInterrupt("id", "value") + assert svc._serialize_interrupt(interrupt) == {"id": "id", "value": "value"} + + auto_event = svc._sanitize_event("plain") + assert auto_event == {"event": "message", "data": "plain"} + + complex_event = svc._sanitize_event( + {"event": "complex", "data": DummyModel("beta")} + ) + assert complex_event["data"] == {"label": "beta", "nested": {"value": 42}} + + assert svc._is_plan_edit_interrupt(SimpleInterrupt("plan", "編集しますか?")) + assert not svc._is_plan_edit_interrupt(SimpleInterrupt("other", "別メッセージ")) + assert not svc._is_run_finished(DummySnapshot(values={}, next=[1])) + assert svc._is_run_finished(DummySnapshot(values={}, next=None)) + + encoded = svc._format_sse({"event": "ping", "data": {"ok": True}}) + assert encoded.startswith("event: ping") + + +def test_extract_and_convert_helpers(service): + svc, _ = service + interrupt_event = { + "event": "on_chain_end", + "data": { + "output": {"__interrupt__": [SimpleInterrupt("resume", "再開しますか")]} + }, + } + extracted = svc._extract_interrupt(interrupt_event) + assert isinstance(extracted, SimpleInterrupt) + + assert svc._extract_interrupt({"event": "noop", "data": "string"}) is None + + class Unknown: + def __repr__(self) -> str: # noqa: D401 + return "unknown" + + payload = {"mixed": [Unknown(), {"inner": Unknown()}]} + converted = svc._convert_model(payload) + assert converted["mixed"][0] == "unknown" + assert converted["mixed"][1]["inner"] == "unknown" + + +def test_recursion_limit_loading(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("GRAPH_RECURSION_LIMIT", "invalid") + + class StubAgent: + def __init__(self) -> None: + pass + + def get_compiled_graph(self) -> DummyGraph: + return DummyGraph() + + monkeypatch.setattr(workflow, "OSSDeepResearchAgent", StubAgent) + svc = workflow.WorkflowService() + assert svc._recursion_limit == 100 + + +def test_resolve_allowed_origins(monkeypatch: pytest.MonkeyPatch): + from src.backend.api import main + + monkeypatch.setenv("CORS_ALLOW_ORIGINS", "https://example.com, https://foo") + origins = main._resolve_allowed_origins() + assert origins == ["https://example.com", "https://foo"] + + monkeypatch.delenv("CORS_ALLOW_ORIGINS", raising=False) + defaults = set(main._resolve_allowed_origins()) + assert {"http://localhost:3000", "http://127.0.0.1:3000"}.issubset(defaults) + + +def test_create_thread_and_diagnostics(service): + svc, _ = service + thread_id = svc.create_thread_id() + import uuid + + uuid.UUID(thread_id) + stats = svc.diagnostics() + assert stats["recursion_limit"] == 7 + + +def test_serialize_state_errors(service): + svc, graph = service + graph.states.pop("missing", None) + with pytest.raises(workflow.StateNotFoundError): + svc._serialize_state("missing") + assert not svc._is_run_finished(None) + + +@pytest.mark.asyncio +async def test_astream_emits_interrupt(service): + svc, _ = service + events = [] + async for event in svc._astream( + {"user_input": "test"}, + thread_id="thread-1", + auto_resume=False, + interrupt_predicate=lambda _: True, + ): + events.append(event) + + names = [event["event"] for event in events] + assert "interrupt" in names diff --git a/tests/backend/tools/test_get_current_date.py b/tests/backend/tools/test_get_current_date.py new file mode 100644 index 0000000..2310f0a --- /dev/null +++ b/tests/backend/tools/test_get_current_date.py @@ -0,0 +1,13 @@ +from datetime import date +from types import SimpleNamespace + +from src.backend.tools import get_current_date as module + + +def test_get_current_date_returns_today(monkeypatch): + monkeypatch.setattr( + module, + "datetime", + SimpleNamespace(date=SimpleNamespace(today=lambda: date(2024, 1, 2))), + ) + assert module.get_current_date.invoke({}) == date(2024, 1, 2) diff --git a/tests/backend/tools/test_search_reflect.py b/tests/backend/tools/test_search_reflect.py new file mode 100644 index 0000000..2704af8 --- /dev/null +++ b/tests/backend/tools/test_search_reflect.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace + +import pytest + +from src.backend.tools import search_reflect + + +class DummyChat: + def __init__(self, **kwargs): + self.kwargs = kwargs + + +class DummyReflectAI: + def __init__(self, _llm): + self.calls = [] + + def __call__(self, query, results): + self.calls.append((query, results)) + return SimpleNamespace( + key_insights=["insight"], + information_gaps=["gap"], + contradictions=["contra"], + improved_queries=["better"], + summary="summary", + ) + + +@pytest.fixture(autouse=True) +def patch_dependencies(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(search_reflect, "ChatOpenAI", DummyChat) + monkeypatch.setattr( + search_reflect, "SearchResultAnalyzeAndReflectAI", DummyReflectAI + ) + + +def test_reflect_on_results_returns_augmented_payload(): + payload = search_reflect.reflect_on_results.invoke( + { + "query": "q", + "results": "r", + "iteration": 1, + "total_iterations": 3, + } + ) + assert payload["key_insights"] == ["insight"] + assert payload["current_iteration"] == 1 + assert payload["total_iterations"] == 3 diff --git a/tests/backend/tools/test_web_research.py b/tests/backend/tools/test_web_research.py new file mode 100644 index 0000000..4141a42 --- /dev/null +++ b/tests/backend/tools/test_web_research.py @@ -0,0 +1,35 @@ +import pytest + +from src.backend.tools import web_research as web_research_module + + +class DummyDDGS: + def __init__(self): + self.calls = [] + + def text(self, query, region, safesearch, backend): + self.calls.append((query, region, safesearch, backend)) + results = [ + {"title": "title", "body": "body", "href": "url"}, + {"href": "only"}, + ] + for item in results: + yield item + + +@pytest.fixture(autouse=True) +def patch_ddgs(monkeypatch: pytest.MonkeyPatch): + dummy = DummyDDGS() + + def factory(): + return dummy + + monkeypatch.setattr(web_research_module, "DDGS", factory) + return dummy + + +def test_web_research_collects_results(patch_ddgs): + results = web_research_module.web_research.invoke({"query": "test"}) + assert results[0]["title"] == "title" + assert results[1]["snippet"] == "" + assert patch_ddgs.calls[0][0] == "test" From 152b51eb3cd7ccee72c2bb98d5608d5adc831ecc Mon Sep 17 00:00:00 2001 From: Milix-M <70957923+Milix-M@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:21:19 +0900 Subject: [PATCH 03/55] =?UTF-8?q?test=E3=81=AB=E3=82=88=E3=82=8A=E7=94=9F?= =?UTF-8?q?=E6=88=90=E3=81=95=E3=82=8C=E3=82=8B=E4=B8=8D=E8=A6=81=E3=81=AA?= =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=BF=E3=82=92ignore=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 505a3b1..4e68787 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ dist/ wheels/ *.egg-info +.pytest_cache/ +.coverage +htmlcov/ # Virtual environments .venv From aba83b4770ea0e29a35ca11b50d8d287e4758008 Mon Sep 17 00:00:00 2001 From: Milix-M <70957923+Milix-M@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:21:39 +0900 Subject: [PATCH 04/55] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AB?= =?UTF-8?q?=E9=96=A2=E3=81=99=E3=82=8B=E6=83=85=E5=A0=B1=E3=82=92README?= =?UTF-8?q?=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index e30cf81..03ef2a9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ DeepReSearch は、LangGraph と LangChain を用いて多段階のウェブリ export OPENROUTER_API_KEY="your-key" ``` +### テストの実行 + +```bash +python -m pytest +``` + +カバレッジと視覚的なレポートが必要な場合は次のように実行します。 + +```bash +python -m pytest --cov=src/backend --cov-report=term-missing --cov-report=html +``` + +HTML レポートは `htmlcov/index.html` に生成され、ブラウザや VS Code の Live Preview で確認できます。 + ### フロントエンド (Next.js) 1. 依存関係をインストールします。 From 424662eb4127c34164626e8d4dbe806edeb0171a Mon Sep 17 00:00:00 2001 From: Milix-M <70957923+Milix-M@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:28:47 +0900 Subject: [PATCH 05/55] =?UTF-8?q?ChatTranscript=E3=82=B3=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88=E3=81=AE=E7=A9=BA=E3=83=A1?= =?UTF-8?q?=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8=E5=87=A6=E7=90=86=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/app/components/ChatTranscript.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/frontend/src/app/components/ChatTranscript.tsx b/src/frontend/src/app/components/ChatTranscript.tsx index 9a977a4..2d876ca 100644 --- a/src/frontend/src/app/components/ChatTranscript.tsx +++ b/src/frontend/src/app/components/ChatTranscript.tsx @@ -6,16 +6,9 @@ interface ChatTranscriptProps { hideEmptyState?: boolean; } -export function ChatTranscript({ messages, hideEmptyState = false }: ChatTranscriptProps) { +export function ChatTranscript({ messages, hideEmptyState: _hideEmptyState = false }: ChatTranscriptProps) { if (messages.length === 0) { - if (hideEmptyState) { - return null; - } - return ( -