From 008c507dee3c3737253f93f85a314995f5e902e8 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 6 Aug 2026 12:39:00 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E3=83=90=E3=82=B9=E8=B7=AF=E7=B7=9A?= =?UTF-8?q?=E3=81=A7GetStationsByLineId=E3=81=8C0=E4=BB=B6=E3=81=AB?= =?UTF-8?q?=E3=81=AA=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E7=A8=AE=E5=88=A5?= =?UTF-8?q?=E3=81=AA=E3=81=97=E9=A7=85=E4=B8=80=E8=A6=A7=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=81=A7=E4=BF=AE=E6=AD=A3=20(#1622)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * バス路線でGetStationsByLineIdが0件になる問題を種別なし駅一覧へのフォールバックで修正 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LM9j6JJciEV1jmhLTt7Hod * 復元したフォールバック関数とテストフィクスチャにdocコメントを追加 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LM9j6JJciEV1jmhLTt7Hod --------- Co-authored-by: Claude --- AGENTS.md | 2 +- .../src/infrastructure/station_repository.rs | 345 +++++++++++++++++- 2 files changed, 345 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e386cd4e..051c6a97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ This guide explains how automation agents and human contributors should work wit - **IPA coverage audit** – Execute `make ipa-audit` when English or romanized CSV names change. This is a read-only report for `data/2!lines.csv`, `data/3!stations.csv`, and `data/4!types.csv`; it does not fail validation, but highlights unresolved tokens and example names so the IPA dictionary can be extended deliberately. ## gRPC Endpoint Overview -- **Stations** – `GetStationById`, `GetStationByIdList`, `GetStationsByGroupId`, `GetStationsByCoordinates`, `GetStationsByLineId`, `GetStationsByName`, `GetStationsByLineGroupId`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. +- **Stations** – `GetStationById`, `GetStationByIdList`, `GetStationsByGroupId`, `GetStationsByCoordinates`, `GetStationsByLineId`, `GetStationsByName`, `GetStationsByLineGroupId`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. `GetStationsByLineId` resolves the line's local train-type group (rail `kind` 0/1 or a `priority > 0` type); when no such group exists — bus lines only carry `BusRoute` (`kind` 7, `priority` 0) variants — it falls back to the line's plain typeless station list so bus stop listings never return empty. - **Lines** – `GetLineById`, `GetLinesByIdList`, `GetLinesByName`. Results include company data and computed line symbols based on repository helpers. - **Routes** – `GetRoutes`, `GetRoutesMinimal`, `GetConnectedRoutes`. The minimal variant returns `RouteMinimalResponse` with deduplicated `LineMinimal` data; paging tokens are currently empty (pagination not implemented). - **Train types** – `GetTrainTypesByStationId`, `GetRouteTypes`. Train types aggregate by line group and include related lines plus optional train type metadata. Rail variants use `TrainTypeKind::{Default, Branch, Rapid, Express, LimitedExpress, HighSpeedRapid, CommuterRapid}` (0-6); bus variants use `BusRoute` (7), which represents a `(route_id, shape_id)` operation pattern (e.g. 循環 / 短ターン / 支線) generated automatically from the configured GTFS bus feeds (Toei Bus, Seibu Bus, Keio Bus) and the converted Tokyu Bus JSON. diff --git a/stationapi/src/infrastructure/station_repository.rs b/stationapi/src/infrastructure/station_repository.rs index e46809e1..89e3afd4 100644 --- a/stationapi/src/infrastructure/station_repository.rs +++ b/stationapi/src/infrastructure/station_repository.rs @@ -1022,13 +1022,119 @@ impl InternalStationRepository { let rows = sqlx::query_as::<_, StationRow>(&query_str) .bind(line_id as i32) .bind(station_id.map(|id| id as i32)) - .fetch_all(conn) + .fetch_all(&mut *conn) .await?; + + // バス路線の種別はBusRoute(kind=7, priority=0)のみで、target_line_groupの + // 鉄道向けフィルタ(kind 0/1・priority>0)に一致せず0件になる。 + // その場合は種別なしの駅一覧(路線の全停留所)へフォールバックする。 + if rows.is_empty() { + return Self::get_by_line_id_without_train_types(line_id, direction_id, conn).await; + } + let stations = rows.into_iter().map(|row| row.into()).collect(); Ok(stations) } + /// 種別情報を使わずに路線の全駅を `e_sort` 順で返す。 + /// + /// `get_by_line_id_with_train_type` で種別グループが見つからない路線 + /// (BusRoute種別しか持たないバス路線)のフォールバックとして使う。 + /// 返される駅の種別関連フィールドはすべて `NULL` になる。 + async fn get_by_line_id_without_train_types( + line_id: u32, + direction_id: Option, + conn: &mut PgConnection, + ) -> Result, DomainError> { + // When direction_id = 1 (上り) or 2 (下り), reverse the order + let order_clause = if matches!(direction_id, Some(1) | Some(2)) { + "ORDER BY s.e_sort DESC, s.station_cd DESC" + } else { + "ORDER BY s.e_sort ASC, s.station_cd ASC" + }; + + let query_str = format!( + r#"SELECT + s.station_cd, + s.station_g_cd, + s.station_name, + s.station_name_k, + s.station_name_r, + s.station_name_rn, + s.station_name_zh, + s.station_name_ko, + s.station_number1, + s.station_number2, + s.station_number3, + s.station_number4, + s.three_letter_code, + s.line_cd, + s.pref_cd, + s.post, + s.address, + s.lon, + s.lat, + s.open_ymd, + s.close_ymd, + s.e_status, + s.e_sort, + l.company_cd, + COALESCE(NULLIF(COALESCE(a.line_name, l.line_name), ''), NULL) AS line_name, + COALESCE(NULLIF(COALESCE(a.line_name_k, l.line_name_k), ''), NULL) AS line_name_k, + COALESCE(NULLIF(COALESCE(a.line_name_h, l.line_name_h), ''), NULL) AS line_name_h, + COALESCE(NULLIF(COALESCE(a.line_name_r, l.line_name_r), ''), NULL) AS line_name_r, + COALESCE(NULLIF(COALESCE(a.line_name_zh, l.line_name_zh), ''), NULL) AS line_name_zh, + COALESCE(NULLIF(COALESCE(a.line_name_ko, l.line_name_ko), ''), NULL) AS line_name_ko, + COALESCE(NULLIF(COALESCE(a.line_color_c, l.line_color_c), ''), NULL) AS line_color_c, + l.line_type, + l.line_symbol1, + l.line_symbol2, + l.line_symbol3, + l.line_symbol4, + l.line_symbol1_color, + l.line_symbol2_color, + l.line_symbol3_color, + l.line_symbol4_color, + l.line_symbol1_shape, + l.line_symbol2_shape, + l.line_symbol3_shape, + l.line_symbol4_shape, + COALESCE(l.average_distance, 0.0)::DOUBLE PRECISION AS average_distance, + NULL::int AS type_id, + NULL::int AS sst_id, + NULL::int AS type_cd, + NULL::int AS line_group_cd, + NULL::int AS pass, + NULL::text AS type_name, + NULL::text AS type_name_k, + NULL::text AS type_name_r, + NULL::text AS type_name_zh, + NULL::text AS type_name_ko, + NULL::text AS color, + NULL::int AS direction, + NULL::int AS kind, + s.transport_type + FROM stations AS s + JOIN lines AS l ON l.line_cd = s.line_cd + LEFT JOIN line_aliases AS la ON la.station_cd = s.station_cd + LEFT JOIN aliases AS a ON a.id = la.alias_cd + WHERE l.line_cd = $1 + AND s.e_status = 0 + AND l.e_status = 0 + {order_clause}"# + ); + + let rows = sqlx::query_as::<_, StationRow>(&query_str) + .bind(line_id as i32) + .fetch_all(conn) + .await?; + + let stations: Vec = rows.into_iter().map(|row| row.into()).collect(); + + Ok(stations) + } + async fn get_by_station_group_id( group_id: u32, conn: &mut PgConnection, @@ -2793,4 +2899,241 @@ mod tests { async fn test_get_route_stops_by_station_cd_reverse_order() { // direction_id=1または2で駅の並び順がDESCに反転されることを確認 } + + mod get_by_line_id_fixtures { + use sqlx::{Connection, Executor, PgConnection}; + use std::env; + use std::sync::atomic::{AtomicU64, Ordering}; + + static SCHEMA_COUNTER: AtomicU64 = AtomicU64::new(0); + + /// 並列実行してもテスト同士が衝突しない一意なスキーマ名を生成する。 + pub fn unique_schema_name() -> String { + let id = SCHEMA_COUNTER.fetch_add(1, Ordering::SeqCst); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("gbli_test_{nanos}_{id}") + } + + /// `TEST_DATABASE_URL`(未設定時は既定のローカルDB)へ接続する。 + pub async fn open_conn() -> PgConnection { + let database_url = env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://test:test@localhost/stationapi_test".to_string()); + PgConnection::connect(&database_url) + .await + .expect("Failed to connect to test database. Set TEST_DATABASE_URL.") + } + + /// 隔離スキーマに get_by_line_id_with_train_type が参照する最小テーブル群を + /// 作成し、鉄道路線(種別あり)とバス路線(BusRoute種別のみ)を投入する。 + pub async fn setup_schema(conn: &mut PgConnection) -> String { + let schema = unique_schema_name(); + conn.execute(format!("CREATE SCHEMA \"{schema}\"").as_str()) + .await + .expect("create schema"); + conn.execute(format!("SET search_path TO \"{schema}\"").as_str()) + .await + .expect("set search_path"); + conn.execute( + r#" + CREATE TABLE stations ( + station_cd INTEGER PRIMARY KEY, + station_g_cd INTEGER NOT NULL, + station_name TEXT NOT NULL, + station_name_k TEXT NOT NULL, + station_name_r TEXT, + station_name_rn TEXT, + station_name_zh TEXT, + station_name_ko TEXT, + station_number1 TEXT, + station_number2 TEXT, + station_number3 TEXT, + station_number4 TEXT, + three_letter_code TEXT, + line_cd INTEGER NOT NULL, + pref_cd INTEGER NOT NULL DEFAULT 13, + post TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + lon DOUBLE PRECISION NOT NULL DEFAULT 139.0, + lat DOUBLE PRECISION NOT NULL DEFAULT 35.0, + open_ymd TEXT NOT NULL DEFAULT '', + close_ymd TEXT NOT NULL DEFAULT '', + e_status INTEGER NOT NULL DEFAULT 0, + e_sort INTEGER NOT NULL, + transport_type INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE lines ( + line_cd INTEGER PRIMARY KEY, + company_cd INTEGER, + line_name TEXT, + line_name_k TEXT, + line_name_h TEXT, + line_name_r TEXT, + line_name_zh TEXT, + line_name_ko TEXT, + line_color_c TEXT, + line_type INTEGER, + line_symbol1 TEXT, + line_symbol2 TEXT, + line_symbol3 TEXT, + line_symbol4 TEXT, + line_symbol1_color TEXT, + line_symbol2_color TEXT, + line_symbol3_color TEXT, + line_symbol4_color TEXT, + line_symbol1_shape TEXT, + line_symbol2_shape TEXT, + line_symbol3_shape TEXT, + line_symbol4_shape TEXT, + average_distance DOUBLE PRECISION, + e_status INTEGER NOT NULL DEFAULT 0, + transport_type INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE types ( + id SERIAL PRIMARY KEY, + type_cd INTEGER UNIQUE NOT NULL, + type_name TEXT NOT NULL, + type_name_k TEXT, + type_name_r TEXT, + type_name_zh TEXT, + type_name_ko TEXT, + color TEXT, + direction INTEGER, + kind INTEGER NOT NULL, + priority INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE station_station_types ( + id SERIAL PRIMARY KEY, + station_cd INTEGER NOT NULL, + type_cd INTEGER NOT NULL, + line_group_cd INTEGER NOT NULL, + pass INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE line_aliases ( + id SERIAL PRIMARY KEY, + station_cd INTEGER NOT NULL, + alias_cd INTEGER NOT NULL + ); + CREATE TABLE aliases ( + id INTEGER PRIMARY KEY, + line_name TEXT, + line_name_k TEXT, + line_name_h TEXT, + line_name_r TEXT, + line_name_zh TEXT, + line_name_ko TEXT, + line_color_c TEXT + ); + -- 鉄道路線: 各駅停車グループ(kind=0)を持つ + INSERT INTO lines (line_cd, company_cd, line_name, e_status, transport_type) + VALUES (100, 1, 'テスト鉄道線', 0, 0), (200, 2, 'テストバス路線', 0, 1); + INSERT INTO types (type_cd, type_name, kind, priority) + VALUES (100, '普通', 0, 0), (900, '循環', 7, 0); + INSERT INTO stations + (station_cd, station_g_cd, station_name, station_name_k, line_cd, e_sort, transport_type) + VALUES + (1, 1, '鉄道駅A', 'テツドウエキエー', 100, 1, 0), + (2, 2, '鉄道駅B', 'テツドウエキビー', 100, 2, 0), + (11, 11, 'バス停A', 'バステイエー', 200, 1, 1), + (12, 12, 'バス停B', 'バステイビー', 200, 2, 1), + (13, 13, 'バス停C', 'バステイシー', 200, 3, 1); + INSERT INTO station_station_types (station_cd, type_cd, line_group_cd, pass) + VALUES + (1, 100, 1000000100, 0), + (2, 100, 1000000100, 0), + (11, 900, 555, 0), + (12, 900, 555, 0), + (13, 900, 555, 0); + "#, + ) + .await + .expect("create get_by_line_id fixtures"); + schema + } + + /// テストで作成した隔離スキーマを破棄する。 + pub async fn drop_schema(conn: &mut PgConnection, schema: &str) { + conn.execute(format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE").as_str()) + .await + .expect("drop schema"); + } + } + + /// バス路線(種別がBusRouteのみ)でも get_by_line_id が停留所一覧を返すこと、 + /// および鉄道路線では従来どおり種別グループの駅列を返すことを確認する。 + #[tokio::test] + #[cfg_attr(not(feature = "integration-tests"), ignore)] + async fn test_get_by_line_id_with_train_type_falls_back_for_bus_lines() { + let mut conn = get_by_line_id_fixtures::open_conn().await; + let schema = get_by_line_id_fixtures::setup_schema(&mut conn).await; + + // 鉄道路線: 各駅停車グループの駅列が返る + let rail_stations = InternalStationRepository::get_by_line_id_with_train_type( + 100, + Some(1), + None, + &mut conn, + ) + .await + .expect("rail stations"); + assert_eq!( + rail_stations + .iter() + .map(|s| (s.station_cd, s.line_group_cd)) + .collect::>(), + vec![(1, Some(1000000100)), (2, Some(1000000100))] + ); + + // バス路線 + 停留所指定: BusRoute種別はグループ選択にマッチしないため、 + // 種別なしの全停留所一覧にフォールバックする + let bus_stations = InternalStationRepository::get_by_line_id_with_train_type( + 200, + Some(11), + None, + &mut conn, + ) + .await + .expect("bus stations"); + assert_eq!( + bus_stations + .iter() + .map(|s| (s.station_cd, s.line_group_cd)) + .collect::>(), + vec![(11, None), (12, None), (13, None)] + ); + + // バス路線 + 停留所未指定(プリセット復元経路)でも同様に返る + let bus_stations_no_seed = + InternalStationRepository::get_by_line_id_with_train_type(200, None, None, &mut conn) + .await + .expect("bus stations without seed"); + assert_eq!( + bus_stations_no_seed + .iter() + .map(|s| s.station_cd) + .collect::>(), + vec![11, 12, 13] + ); + + // direction_id指定時は並び順が反転する + let bus_stations_reversed = InternalStationRepository::get_by_line_id_with_train_type( + 200, + Some(11), + Some(1), + &mut conn, + ) + .await + .expect("bus stations reversed"); + assert_eq!( + bus_stations_reversed + .iter() + .map(|s| s.station_cd) + .collect::>(), + vec![13, 12, 11] + ); + + get_by_line_id_fixtures::drop_schema(&mut conn, &schema).await; + } } From 3dc065dd596b191e1138284febca56b4938d4438 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 12 Aug 2026 08:52:46 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=E5=88=97=E8=BB=8A=E7=A8=AE=E5=88=A5?= =?UTF-8?q?=E3=80=8C=E3=81=BF=E3=81=99=E3=81=9A=E3=80=8D=E3=82=92=E9=95=B7?= =?UTF-8?q?=E9=87=8E=E6=96=B9=E9=9D=A2=E3=83=BB=E9=A3=AF=E7=94=B0=E6=96=B9?= =?UTF-8?q?=E9=9D=A2=E3=81=AE2=E7=B3=BB=E7=B5=B1=E3=81=A7=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=20(#1624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 飯田線・中央本線・篠ノ井線・信越本線を直通する快速「みすず」を、 2016年3月26日改正の停車パターンで追加する。 長野行きのみ停車する駅(大田切・赤木・下島・田畑・伊那新町)と 飯田行きのみ停車する駅(桜町・伊那上郷・伊那本郷)があるため、 既存の宗谷(type_cd 216/217)に倣い direction 違いの2種別に分割し、 一部通過(pass=2)を使わず停車・通過で表現する。 Claude-Session: https://claude.ai/code/session_01F7x9LCgjfC5cbQ6n9xfN3Y Co-authored-by: Claude Opus 5 --- data/4!types.csv | 2 + data/5!station_station_types.csv | 106 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/data/4!types.csv b/data/4!types.csv index 367b9289..05cb1100 100644 --- a/data/4!types.csv +++ b/data/4!types.csv @@ -330,3 +330,5 @@ DEFAULT,538,モーニングライナー,モーニングライナー,Morning Line DEFAULT,539,イブニングライナー,イブニングライナー,Evening Liner,Evening Liner,이브닝 라이너,#9400d3,0,4,0,,COMPLETE DEFAULT,540,臨時ライナー,リンジライナー,Special Liner,临时Liner,임시 라이너,#add8e6,0,4,0,,COMPLETE DEFAULT,541,GRAN 天空,グランテンクウ,GRAN Tenkū,GRAN 天空,GRAN 텐쿠,#AD7E2A,0,4,0,,COMPLETE +DEFAULT,542,みすず,ミスズ,Misuzu,水篶,미스즈,#DC143C,2,2,0,長野方面,COMPLETE +DEFAULT,543,みすず,ミスズ,Misuzu,水篶,미스즈,#DC143C,1,2,0,飯田方面,COMPLETE diff --git a/data/5!station_station_types.csv b/data/5!station_station_types.csv index 2330d4ae..10fefe87 100644 --- a/data/5!station_station_types.csv +++ b/data/5!station_station_types.csv @@ -41124,3 +41124,109 @@ DEFAULT,1171629,125,1168,0,海田市 DEFAULT,1171630,125,1168,1,向洋 DEFAULT,1171631,125,1168,1,天神川 DEFAULT,1171632,125,1168,0,広島 +DEFAULT,1141410,542,1169,0,飯田 +DEFAULT,1141411,542,1169,1,桜町 +DEFAULT,1141412,542,1169,1,伊那上郷 +DEFAULT,1141413,542,1169,0,元善光寺 +DEFAULT,1141414,542,1169,1,下市田 +DEFAULT,1141415,542,1169,0,市田 +DEFAULT,1141416,542,1169,1,下平 +DEFAULT,1141417,542,1169,1,山吹 +DEFAULT,1141418,542,1169,0,伊那大島 +DEFAULT,1141419,542,1169,0,上片桐 +DEFAULT,1141420,542,1169,1,伊那田島 +DEFAULT,1141421,542,1169,1,高遠原 +DEFAULT,1141422,542,1169,0,七久保 +DEFAULT,1141423,542,1169,1,伊那本郷 +DEFAULT,1141424,542,1169,0,飯島 +DEFAULT,1141425,542,1169,1,田切 +DEFAULT,1141426,542,1169,0,伊那福岡 +DEFAULT,1141427,542,1169,0,小町屋 +DEFAULT,1141428,542,1169,0,駒ケ根 +DEFAULT,1141429,542,1169,0,大田切 +DEFAULT,1141430,542,1169,0,宮田 +DEFAULT,1141431,542,1169,0,赤木 +DEFAULT,1141432,542,1169,0,沢渡 +DEFAULT,1141433,542,1169,0,下島 +DEFAULT,1141434,542,1169,0,伊那市 +DEFAULT,1141435,542,1169,0,伊那北 +DEFAULT,1141436,542,1169,0,田畑 +DEFAULT,1141437,542,1169,0,北殿 +DEFAULT,1141438,542,1169,0,木ノ下 +DEFAULT,1141439,542,1169,0,伊那松島 +DEFAULT,1141440,542,1169,0,沢 +DEFAULT,1141441,542,1169,0,羽場 +DEFAULT,1141442,542,1169,0,伊那新町 +DEFAULT,1141443,542,1169,0,宮木 +DEFAULT,1141444,542,1169,0,辰野 +DEFAULT,1131150,542,1169,0,辰野 +DEFAULT,1131149,542,1169,0,川岸 +DEFAULT,1131147,542,1169,0,岡谷 +DEFAULT,1131148,542,1169,0,みどり湖 +DEFAULT,1131153,542,1169,0,塩尻 +DEFAULT,1141201,542,1169,0,塩尻 +DEFAULT,1141202,542,1169,0,広丘 +DEFAULT,1141203,542,1169,0,村井 +DEFAULT,1141215,542,1169,0,平田 +DEFAULT,1141204,542,1169,0,南松本 +DEFAULT,1141205,542,1169,0,松本 +DEFAULT,1141206,542,1169,0,田沢 +DEFAULT,1141207,542,1169,0,明科 +DEFAULT,1141208,542,1169,0,西条 +DEFAULT,1141209,542,1169,0,坂北 +DEFAULT,1141210,542,1169,0,聖高原 +DEFAULT,1141211,542,1169,0,冠着 +DEFAULT,1141212,542,1169,0,姨捨 +DEFAULT,1141213,542,1169,0,稲荷山 +DEFAULT,1141214,542,1169,0,篠ノ井 +DEFAULT,1140301,542,1169,0,篠ノ井 +DEFAULT,1140302,542,1169,0,今井 +DEFAULT,1140303,542,1169,0,川中島 +DEFAULT,1140304,542,1169,0,安茂里 +DEFAULT,1140305,542,1169,0,長野 +DEFAULT,1141410,543,1170,0,飯田 +DEFAULT,1141411,543,1170,0,桜町 +DEFAULT,1141412,543,1170,0,伊那上郷 +DEFAULT,1141413,543,1170,0,元善光寺 +DEFAULT,1141414,543,1170,1,下市田 +DEFAULT,1141415,543,1170,0,市田 +DEFAULT,1141416,543,1170,1,下平 +DEFAULT,1141417,543,1170,1,山吹 +DEFAULT,1141418,543,1170,0,伊那大島 +DEFAULT,1141419,543,1170,0,上片桐 +DEFAULT,1141420,543,1170,1,伊那田島 +DEFAULT,1141421,543,1170,1,高遠原 +DEFAULT,1141422,543,1170,0,七久保 +DEFAULT,1141423,543,1170,0,伊那本郷 +DEFAULT,1141424,543,1170,0,飯島 +DEFAULT,1141425,543,1170,1,田切 +DEFAULT,1141426,543,1170,0,伊那福岡 +DEFAULT,1141427,543,1170,0,小町屋 +DEFAULT,1141428,543,1170,0,駒ケ根 +DEFAULT,1141429,543,1170,1,大田切 +DEFAULT,1141430,543,1170,0,宮田 +DEFAULT,1141431,543,1170,1,赤木 +DEFAULT,1141432,543,1170,0,沢渡 +DEFAULT,1141433,543,1170,1,下島 +DEFAULT,1141434,543,1170,0,伊那市 +DEFAULT,1141435,543,1170,0,伊那北 +DEFAULT,1141436,543,1170,1,田畑 +DEFAULT,1141437,543,1170,0,北殿 +DEFAULT,1141438,543,1170,0,木ノ下 +DEFAULT,1141439,543,1170,0,伊那松島 +DEFAULT,1141440,543,1170,0,沢 +DEFAULT,1141441,543,1170,0,羽場 +DEFAULT,1141442,543,1170,1,伊那新町 +DEFAULT,1141443,543,1170,0,宮木 +DEFAULT,1141444,543,1170,0,辰野 +DEFAULT,1131150,543,1170,0,辰野 +DEFAULT,1131149,543,1170,0,川岸 +DEFAULT,1131147,543,1170,0,岡谷 +DEFAULT,1131148,543,1170,0,みどり湖 +DEFAULT,1131153,543,1170,0,塩尻 +DEFAULT,1141201,543,1170,0,塩尻 +DEFAULT,1141202,543,1170,0,広丘 +DEFAULT,1141203,543,1170,0,村井 +DEFAULT,1141215,543,1170,0,平田 +DEFAULT,1141204,543,1170,0,南松本 +DEFAULT,1141205,543,1170,0,松本 From aa5af3be1497c722cc675bf10727bd84e9fd7421 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 12 Aug 2026 09:08:51 +0900 Subject: [PATCH 3/5] =?UTF-8?q?make=20ipa-audit=E3=81=8C=E6=97=A7IPA?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E5=90=8D=E3=81=AE=E5=8F=82=E7=85=A7=E3=81=A7?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=91=E3=82=A4=E3=83=AB=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20(#1625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/ipa_audit.rs は stationapi/src/domain/ipa.rs を include! して単独の rustc でビルドするため cargo のターゲットに含まれず、ipa.rs が TTS セグメント 方式へリファクタされた際の関数リネームに追従できていなかった。 - romanized_name_to_ipa() は廃止済みのため station_name_to_ipa("", Some(name)) に置換する。カタカナ引数を空にすることでカタカナへのフォールバックを抑止し、 ローマ字表記だけで IPA を解決できるかという元の判定意図を保つ。 - word_to_ipa() は後継の word_to_tts_segments() に置換する。 Claude-Session: https://claude.ai/code/session_01Px4SXdeBULB87KqD1TzKfy Co-authored-by: Claude Opus 5 --- tools/ipa_audit.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/ipa_audit.rs b/tools/ipa_audit.rs index 40828825..cffe4d41 100644 --- a/tools/ipa_audit.rs +++ b/tools/ipa_audit.rs @@ -65,12 +65,13 @@ fn audit_dataset(dataset: &Dataset) -> Result<(), Box> { } total_names += 1; - if romanized_name_to_ipa(name_roman).is_none() { + // カタカナ側にフォールバックさせず、ローマ字表記だけで IPA を解決できるか判定する。 + if station_name_to_ipa("", Some(name_roman)).is_none() { unresolved_names += 1; } for token in extract_tokens(name_roman) { - if word_to_ipa(&token).is_some() { + if word_to_tts_segments(&token).is_some() { continue; } let entry = unresolved_tokens.entry(token).or_default(); From 7a372ea702d6aad41e183819cc49af9888b1eeac Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 12 Aug 2026 09:30:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=E7=B4=80=E4=BC=8A=E7=B4=B0=E5=B7=9D?= =?UTF-8?q?=E3=81=AE=E8=8B=B1=E5=AD=97=E8=A1=A8=E8=A8=98=E3=81=AE=E7=B6=B4?= =?UTF-8?q?=E3=82=8A=E8=AA=A4=E3=82=8AKii-Hosoakwa=E3=82=92Kii-Hosokawa?= =?UTF-8?q?=E3=81=AB=E4=BF=AE=E6=AD=A3=20(#1627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit station_name_r / station_name_rn がいずれも "Kii-Hosoakwa" となっており、 "Hosokawa" の k と a が入れ替わった綴り誤りだった。カタカナ (キイホソカワ) と住所 (和歌山県伊都郡高野町大字細川) から Hosokawa が正しい。 make ipa-audit で唯一の未解決名として残っていたもので、修正により romaji_to_katakana がそのまま解決し kʲi.i hosokawa を返すようになる。 cargo run -p data_validator は [VALID] No errors reported。 Claude-Session: https://claude.ai/code/session_01Px4SXdeBULB87KqD1TzKfy Co-authored-by: Claude Opus 5 --- data/3!stations.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/3!stations.csv b/data/3!stations.csv index 3894fc76..d3596d16 100644 --- a/data/3!stations.csv +++ b/data/3!stations.csv @@ -7013,7 +7013,7 @@ station_cd,station_g_cd,station_name,station_name_k,station_name_r,station_name_ 3200737,3200737,高野下,コウヤシタ,Konoshita,Konoshita,高野下,고야시타,81,,,,,32007,30,648-0141,和歌山県伊都郡九度山町大字椎出8-1,135.566359,34.271635,0000-00-00,0000-00-00,0,3200737 3200738,3200738,下古沢,シモコサワ,Shimokosawa,Shimokosawa,下古泽,시모코사와,82,,,,,32007,30,648-0145,和歌山県伊都郡九度山町大字下古沢,135.553207,34.266497,0000-00-00,0000-00-00,0,3200738 3200739,3200739,上古沢,カミコサワ,Kamikosawa,Kamikosawa,上古泽,가미코사와,83,,,,,32007,30,648-0143,和歌山県伊都郡九度山町大字上古沢,135.548025,34.257362,0000-00-00,0000-00-00,0,3200739 -3200740,3200740,紀伊細川,キイホソカワ,Kii-Hosoakwa,Kii-Hosoakwa,纪伊细川,기이호소카와,84,,,,,32007,30,648-0262,和歌山県伊都郡高野町大字細川732,135.553705,34.235723,0000-00-00,0000-00-00,0,3200740 +3200740,3200740,紀伊細川,キイホソカワ,Kii-Hosokawa,Kii-Hosokawa,纪伊细川,기이호소카와,84,,,,,32007,30,648-0262,和歌山県伊都郡高野町大字細川732,135.553705,34.235723,0000-00-00,0000-00-00,0,3200740 3200741,3200741,紀伊神谷,キイカミヤ,Kii-Kamiya,Kii-Kamiya,纪伊神谷,기이카미야,85,,,,,32007,30,648-0200,和歌山県伊都郡高野町大字神谷,135.571034,34.242852,0000-00-00,0000-00-00,0,3200741 3200742,3200742,極楽橋,ゴクラクバシ,Gokurakubashi,Gokurakubashi,极乐桥,고쿠라쿠바시,86,,,,,32007,30,648-0211,和歌山県伊都郡高野町大字高野山国有林第8林班,135.582078,34.23435,0000-00-00,0000-00-00,0,3200742 3200801,3200742,極楽橋,ゴクラクバシ,Gokurakubashi,Gokurakubashi,极乐桥,고쿠라쿠바시,86,,,,,32008,30,648-0211,和歌山県伊都郡高野町大字高野山国有林第8林班,135.582078,34.23435,0000-00-00,0000-00-00,0,3200801 From 4e73e8326e736d56c580160f1dd78e65a34424d9 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 12 Aug 2026 09:58:13 +0900 Subject: [PATCH 5/5] =?UTF-8?q?IPA=E8=BE=9E=E6=9B=B8=E3=82=92=E6=8B=A1?= =?UTF-8?q?=E5=85=85=E3=81=97=E3=81=A6=E3=83=AD=E3=83=BC=E3=83=9E=E5=AD=97?= =?UTF-8?q?=E9=A7=85=E5=90=8D=E3=81=AE=E6=9C=AA=E8=A7=A3=E6=B1=BA=E3=82=92?= =?UTF-8?q?61=E4=BB=B6=E3=81=8B=E3=82=891=E4=BB=B6=E3=81=AB=E5=89=8A?= =?UTF-8?q?=E6=B8=9B=20(#1626)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make ipa-audit が報告する未解決名 61 件 (lines 1 / stations 59 / train_types 1) を解消する。 - lookup_english_word_ipa に 60 語を追加する。英語・カタカナ外来語由来の語 (Disney / Resort / Bayside / Screen / Vogel など) と、romaji_to_katakana が解釈できない訓令式・非ヘボン式表記 (Sirokanedai / Tamati / Mitumatu / Hatchonawate / 3chōme など) の両方を対象とする。日本語読みの IPA は katakana_to_ipa() の出力に合わせ、長音のみ既存エントリと同じ ː 表記に畳んだ。 - 日本式ローマ字のサーカムフレックス長音 (â/î/û/ê/ô) をトークン文字として 受け付け、マクロンと同じ規則で母音列へ展開する。従来は区切り文字扱いのため "Kyûjômae" が "Ky" / "j" / "mae" に割れて読みが崩れていた。 - camelCase 分割で現れる "sea" を追加する。"DisneySea" が「セア」と誤読されて いたが、名前全体としては解決扱いのため監査に現れないケースだった。 CSV 全 12,094 名称の name_roman_ipa を変更前後で突き合わせ、差分 56 件が すべて意図した改善であることを確認済み。残る未解決 1 件は Kii-Hosoakwa で、 CSV 側の綴り誤り (正: Kii-Hosokawa) のため辞書では扱わない。 Claude-Session: https://claude.ai/code/session_01Px4SXdeBULB87KqD1TzKfy Co-authored-by: Claude Opus 5 --- stationapi/src/domain/ipa.rs | 197 +++++++++++++++++++++++++++++++++-- 1 file changed, 191 insertions(+), 6 deletions(-) diff --git a/stationapi/src/domain/ipa.rs b/stationapi/src/domain/ipa.rs index 37d3e441..582c580c 100644 --- a/stationapi/src/domain/ipa.rs +++ b/stationapi/src/domain/ipa.rs @@ -397,7 +397,30 @@ fn is_name_token_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!( c, - '\'' | '.' | 'Ā' | 'Ī' | 'Ū' | 'Ē' | 'Ō' | 'ā' | 'ī' | 'ū' | 'ē' | 'ō' + '\'' | '.' + | 'Ā' + | 'Ī' + | 'Ū' + | 'Ē' + | 'Ō' + | 'ā' + | 'ī' + | 'ū' + | 'ē' + | 'ō' + // 日本式ローマ字のサーカムフレックス長音 (Kyûjômae / Sessokyô / Okuôi)。 + // トークン文字に含めないと長音記号が区切り文字扱いになり、 + // "Kyûjômae" が "Ky" / "j" / "mae" に割れて読みが崩れる。 + | 'Â' + | 'Î' + | 'Û' + | 'Ê' + | 'Ô' + | 'â' + | 'î' + | 'û' + | 'ê' + | 'ô' ) } @@ -411,13 +434,15 @@ fn normalize_name_token(token: &str) -> String { .to_lowercase() } +/// マクロン (ā/ī/ū/ē/ō) とサーカムフレックス (â/î/û/ê/ô) の長音表記を、 +/// romaji_to_katakana が解釈できる母音の並びへ展開する。 fn normalize_name_char(c: char) -> Vec { match c { - 'Ā' | 'ā' => vec!['a', 'a'], - 'Ī' | 'ī' => vec!['i', 'i'], - 'Ū' | 'ū' => vec!['u', 'u'], - 'Ē' | 'ē' => vec!['e', 'i'], - 'Ō' | 'ō' => vec!['o', 'u'], + 'Ā' | 'ā' | 'Â' | 'â' => vec!['a', 'a'], + 'Ī' | 'ī' | 'Î' | 'î' => vec!['i', 'i'], + 'Ū' | 'ū' | 'Û' | 'û' => vec!['u', 'u'], + 'Ē' | 'ē' | 'Ê' | 'ê' => vec!['e', 'i'], + 'Ō' | 'ō' | 'Ô' | 'ô' => vec!['o', 'u'], _ => vec![c], } } @@ -685,6 +710,74 @@ fn lookup_english_word_ipa(word: &str) -> Option<&'static str> { "t" => Some("tiː"), "trans" => Some("tɹæns"), "zoological" => Some("zuːəlɑdʒɪkəl"), + // ここから下は `make ipa-audit` で未解決として報告された語。 + // 英語・カタカナ外来語由来のもの。 + "bayside" => Some("beɪsaɪd"), + "disney" => Some("dɪzni"), + "disneyland" => Some("dɪznilænd"), + // "DisneySea" は camelCase 分割で "Disney" + "Sea" になる。"sea" を登録しないと + // romaji_to_katakana が「セア」と読んでしまい、監査には出ないまま誤読が残る。 + "sea" => Some("siː"), + "girls'" => Some("gɝlz"), + "gran" => Some("gɹɑːn"), + "playpia" => Some("pleɪpiə"), + "portopia" => Some("pɔɹtoʊpiə"), + "pref.art" => Some("pɹɛf ɑɹt"), + "pref.sports" => Some("pɹɛf spɔɹts"), + "race" => Some("ɹeɪs"), + "resort" => Some("ɹɪzɔɹt"), + "river" => Some("ɹɪvɚ"), + "royce'" => Some("ɹɔɪs"), + "ruins" => Some("ɹuːɪnz"), + "screen" => Some("skɹiːn"), + "shop" => Some("ʃɑp"), + "socio" => Some("soʊʃioʊ"), + // 「ウェスパ椿山」の "WeSPa" は camelCase 分割で "We" + "SPa" に割れるため、 + // 英単語の we (wiː) ではなくカタカナ読みに寄せた wɛ を割り当てる。 + "we" => Some("wɛ"), + "spa" => Some("spɑː"), + "st" => Some("seɪnt"), + "tennis" => Some("tɛnɪs"), + "toyopet" => Some("toʊjoʊpɛt"), + "univ" => Some("juːnəvɚsəti"), + "vogel" => Some("foʊgəl"), + "yomiuriland" => Some("jomiɯɾilænd"), + "yrp" => Some("waɪ ɑɹ piː"), + // 訓令式・非ヘボン式のローマ字表記など、romaji_to_katakana が解釈できない日本語読み。 + // IPA は katakana_to_ipa() の出力に合わせ、長音のみ既存エントリと同じ ː 表記に畳んでいる。 + "3choume" => Some("sant͡ɕoːme"), + "4choume" => Some("jont͡ɕoːme"), + "daiunzi" => Some("daiɯɲdʑi"), + "etchuujima" => Some("ett͡ɕɯːdʑima"), + "golfjomae" => Some("gɑlf dʑoːmae"), + "hasirano" => Some("haɕiɾano"), + "hatcho" => Some("hatt͡ɕoː"), + "hatchome" => Some("hatt͡ɕoːme"), + "hatchonawate" => Some("hatt͡ɕoːnawate"), + "hatchoubori" => Some("hatt͡ɕoːboɾi"), + "hatcomuta" => Some("hatt͡ɕoːmɯta"), + "inadazutumi" => Some("inadadzɯt͡sɯmi"), + "inuboh" => Some("inɯboː"), + "itchumae" => Some("itt͡ɕɯːmae"), + "itihana" => Some("it͡ɕihana"), + "juitchome" => Some("dʑɯːitt͡ɕoːme"), + "kohjiro" => Some("koːdʑiɾo"), + "kozyou" => Some("kodʑoː"), + "mitumatu" => Some("mit͡sɯmat͡sɯ"), + "mitusawa" => Some("mit͡sɯsawa"), + "nijuhatchome" => Some("ɲidʑɯːhatt͡ɕoːme"), + "oh" => Some("oː"), + "ohda" => Some("oːda"), + "ohmisaki" => Some("oːmisakʲi"), + "ohshou" => Some("oːɕoː"), + "saidaizi" => Some("saidaidʑi"), + "saigoh" => Some("saigoː"), + "shicikenjaya" => Some("ɕit͡ɕikeɲdʑaja"), + "sirokanedai" => Some("ɕiɾokanedai"), + "sizuoka" => Some("ɕizɯoka"), + "suzi" => Some("sɯdʑi"), + "tamati" => Some("tamat͡ɕi"), + "tsudumigaura" => Some("t͡sɯdzɯmigaɯɾa"), _ => None, } } @@ -1552,6 +1645,98 @@ mod tests { ); } + #[test] + fn test_station_name_ipa_supports_kunrei_style_romanization() { + // 訓令式 (si/ti/tu/zi) は romaji_to_katakana が解釈できないため辞書で補う。 + assert_eq!( + station_name_to_ipa("シロカネダイ", Some("Sirokanedai")), + Some("ɕilokanedai".to_string()) + ); + assert_eq!( + station_name_to_ipa("タマチ", Some("Tamati")), + Some("tamat͡ɕi".to_string()) + ); + assert_eq!( + station_name_to_ipa("シンサイダイジチョウスジ", Some("Shin-saidaizi-cho-suzi")), + Some("ɕiɴ saidaidʑi t͡ɕo sɯdʑi".to_string()) + ); + } + + #[test] + fn test_station_name_ipa_expands_circumflex_long_vowels() { + // サーカムフレックスをトークン文字として扱わないと "Ky" / "j" / "mae" に割れる。 + assert_eq!( + station_name_to_ipa("キュウジョウマエ", Some("Kyûjômae")), + Some("kʲɯ.ɯdʑo.ɯma.e".to_string()) + ); + assert_eq!( + station_name_to_ipa("セッソキョウオンセン", Some("Sessokyô Onsen")), + Some("sessokʲo.ɯ onseɴ".to_string()) + ); + } + + #[test] + fn test_station_name_ipa_supports_oh_style_long_vowels() { + // 「オウ」を oh と綴る表記 (犬吠 / 西郷 / 海の王迎)。 + assert_eq!( + station_name_to_ipa("イヌボウ", Some("Inuboh")), + Some("inɯboː".to_string()) + ); + assert_eq!( + station_name_to_ipa("ウミノオウムカエ", Some("Umino-oh-mukae")), + Some("ɯmino oː mɯka.e".to_string()) + ); + } + + #[test] + fn test_station_name_ipa_supports_chome_variants() { + assert_eq!( + station_name_to_ipa("ニシハッチョウメ", Some("Nishi hatchome")), + Some("ɲiɕi hatt͡ɕoːme".to_string()) + ); + assert_eq!( + station_name_to_ipa("テヅカヤマサンチョウメ", Some("Tezukayama-3chōme")), + Some("tezɯkajama sant͡ɕoːme".to_string()) + ); + } + + #[test] + fn test_station_name_ipa_supports_facility_brand_words() { + assert_eq!( + station_name_to_ipa( + "トウキョウディズニーランド・ステーション", + Some("Tokyo Disneyland Station") + ), + Some("tokʲo dɪznilænd steɪʃən".to_string()) + ); + // "DisneySea" は camelCase 分割で "Disney" + "Sea" になる。 + assert_eq!( + station_name_to_ipa( + "トウキョウディズニーシー・ステーション", + Some("Tokyo DisneySea Station") + ), + Some("tokʲo dɪzni siː steɪʃən".to_string()) + ); + } + + #[test] + fn test_station_name_ipa_supports_abbreviated_english_words() { + // "St." / "Univ." は normalize_name_token が末尾のピリオドを落とす。 + assert_eq!( + station_name_to_ipa("セイマリアビョウインマエ", Some("St. Mary's Hospital")), + Some("seɪnt mɛɹiz hɑspɪtəl".to_string()) + ); + assert_eq!( + station_name_to_ipa("アイダイイガクブミナミグチ", Some("Ehime Univ. Hospital")), + Some("eçime juːnəvɚsəti hɑspɪtəl".to_string()) + ); + // "Pref.Art" はピリオドが語中にあるため 1 トークンのまま引かれる。 + assert_eq!( + station_name_to_ipa("ケンリツビジュツカンマエ", Some("Pref.Art Museum")), + Some("pɹɛf ɑɹt mjuːziəm".to_string()) + ); + } + #[test] fn test_station_name_ipa_supports_meitetsu_prefixed_station_names_from_csv() { let cases = [