Skip to main content

recall_echo/graph/
ingest.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Ingestion orchestrator — chunk → episode → extract → dedup → relationships.
6
7use std::collections::HashMap;
8
9use futures::stream::{self, StreamExt};
10
11use super::confidence::{ExtractionContext, Observation, Provenance};
12use super::crud;
13use super::dedup::{self, Resolution, ResolvedEntity};
14use super::error::GraphError;
15use super::extract;
16use super::llm::{LlmProvider, TokenUsage};
17use super::types::*;
18use super::utility;
19use super::GraphMemory;
20
21/// Maximum number of concurrent LLM calls during extraction and dedup.
22const LLM_CONCURRENCY: usize = 10;
23
24/// Role headings written by the archive pipeline, lower-cased.
25const USER_TURN_HEADING: &str = "### user";
26const ASSISTANT_TURN_HEADING: &str = "### assistant";
27
28/// How one ingestion run assigns a provenance class to what it writes.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub enum ProvenancePolicy {
31    /// Read the class off each chunk's conversation turn roles. Text with no
32    /// visible human-only turn is treated as the agent's own.
33    #[default]
34    FromTurnRoles,
35    /// Stamp every episode in the run with one class — document ingestion
36    /// (`--external`), and any caller that knows better than the heuristic.
37    Fixed(Provenance),
38}
39
40impl ProvenancePolicy {
41    /// The class this policy assigns to one chunk of archive text.
42    #[must_use]
43    pub fn classify(self, chunk: &str) -> Provenance {
44        match self {
45            Self::Fixed(provenance) => provenance,
46            Self::FromTurnRoles => infer_from_turn_roles(chunk),
47        }
48    }
49}
50
51/// Where a run of ingestion is reading from, and what that makes its output.
52///
53/// Carried as one value rather than three parameters because every write the
54/// run performs — episodes and confidence updates alike — must agree on it.
55#[derive(Debug, Clone)]
56pub struct IngestContext {
57    session_id: String,
58    log_number: Option<u32>,
59    provenance: ProvenancePolicy,
60}
61
62impl IngestContext {
63    /// Context for a conversation archive: provenance is read off turn roles.
64    #[must_use]
65    pub fn new(session_id: impl Into<String>, log_number: Option<u32>) -> Self {
66        Self {
67            session_id: session_id.into(),
68            log_number,
69            provenance: ProvenancePolicy::default(),
70        }
71    }
72
73    /// Override the class assignment for the whole run.
74    #[must_use]
75    pub fn with_provenance(mut self, provenance: ProvenancePolicy) -> Self {
76        self.provenance = provenance;
77        self
78    }
79
80    /// Force every episode in the run to one class, or infer per chunk when
81    /// `provenance` is `None`. The shape a CLI `--external` flag arrives in.
82    #[must_use]
83    pub fn with_override(self, provenance: Option<Provenance>) -> Self {
84        match provenance {
85            Some(class) => self.with_provenance(ProvenancePolicy::Fixed(class)),
86            None => self,
87        }
88    }
89
90    /// Session this text belongs to.
91    #[must_use]
92    pub fn session_id(&self) -> &str {
93        &self.session_id
94    }
95
96    /// Archive log number, when the text came from a numbered archive.
97    #[must_use]
98    pub fn log_number(&self) -> Option<u32> {
99        self.log_number
100    }
101}
102
103/// Infer authorship from the role headings the archive pipeline writes.
104///
105/// A chunk is credited to the human only when every role heading in it is a
106/// user turn. Anything else — mixed turns, assistant turns, or text with no
107/// headings at all (pipeline documents, summaries) — is the agent's own, per
108/// the conservative default: never over-credit.
109fn infer_from_turn_roles(chunk: &str) -> Provenance {
110    let mut saw_user = false;
111    for line in chunk.lines() {
112        let heading = line.trim().to_lowercase();
113        if heading == ASSISTANT_TURN_HEADING {
114            return Provenance::SelfGenerated;
115        }
116        if heading == USER_TURN_HEADING {
117            saw_user = true;
118        }
119    }
120
121    if saw_user {
122        Provenance::User
123    } else {
124        Provenance::SelfGenerated
125    }
126}
127
128/// Ingest a conversation archive into the knowledge graph.
129///
130/// Flow:
131/// 1. Chunk the conversation text
132/// 2. Create an Episode for each chunk, stamped with its provenance (always,
133///    even without LLM)
134/// 3. If LLM provided: extract entities/relationships, dedup, store
135/// 4. Return a report of what was created/merged/skipped
136pub async fn ingest_archive(
137    gm: &GraphMemory,
138    archive_text: &str,
139    context: &IngestContext,
140    llm: Option<&dyn LlmProvider>,
141) -> Result<IngestionReport, GraphError> {
142    let mut report = IngestionReport::default();
143
144    let chunks = extract::chunk_conversation(archive_text, 500);
145    if chunks.is_empty() {
146        return Ok(report);
147    }
148
149    // Create episodes for each chunk, each stamped with its own authorship —
150    // one archive can hold both the human's words and the agent's.
151    for (i, chunk) in chunks.iter().enumerate() {
152        let abstract_text = build_episode_abstract(chunk);
153        let episode = NewEpisode {
154            session_id: context.session_id.clone(),
155            abstract_text,
156            overview: None,
157            content: Some(chunk.clone()),
158            log_number: context.log_number,
159        };
160
161        match gm
162            .add_episode_from(episode, context.provenance.classify(chunk))
163            .await
164        {
165            Ok(_) => report.episodes_created += 1,
166            Err(e) => {
167                report.errors.push(format!("episode chunk {i}: {e}"));
168            }
169        }
170    }
171
172    // If LLM provided, run extraction on all chunks
173    if let Some(llm) = llm {
174        process_extraction(gm, &chunks, context, llm, &mut report).await?;
175    }
176
177    Ok(report)
178}
179
180/// Run LLM extraction on an archive text without creating episodes.
181///
182/// Use this when episodes already exist (e.g., backfill extraction on
183/// previously-ingested archives).
184pub async fn extract_from_archive(
185    gm: &GraphMemory,
186    archive_text: &str,
187    context: &IngestContext,
188    llm: &dyn LlmProvider,
189) -> Result<IngestionReport, GraphError> {
190    let mut report = IngestionReport::default();
191
192    let chunks = extract::chunk_conversation(archive_text, 500);
193    if chunks.is_empty() {
194        return Ok(report);
195    }
196
197    process_extraction(gm, &chunks, context, llm, &mut report).await?;
198
199    Ok(report)
200}
201
202/// Extract one chunk, tagged with its index.
203///
204/// A named async fn rather than an inline `async move` block: the inline form
205/// makes the resulting stream non-`Send` (the closure would have to implement
206/// `FnOnce` for any two lifetimes), and the serve daemon runs ingestion inside
207/// a spawned tokio task.
208async fn extract_indexed(
209    llm: &dyn LlmProvider,
210    chunk: &str,
211    session_id: &str,
212    log_number: Option<u32>,
213    index: usize,
214) -> (usize, Result<ChunkExtraction, GraphError>) {
215    let result = extract::extract_from_chunk(llm, chunk, session_id, log_number).await;
216    (index, result)
217}
218
219/// What one extraction call produced, and what it reported spending.
220type ChunkExtraction = (ExtractionResult, Option<TokenUsage>);
221
222/// Tokens assumed for one extraction call when the provider reports none:
223/// system prompt + chunk input + output.
224const ESTIMATED_EXTRACTION_TOKENS: u64 = 2_500;
225
226/// Tokens assumed for one dedup call when the provider reports none:
227/// prompt + decision.
228const ESTIMATED_DEDUP_TOKENS: u64 = 600;
229
230/// Shared extraction logic — parallel extraction, sequential dedup.
231///
232/// Five phases:
233/// 1. Extract all chunks in parallel (up to LLM_CONCURRENCY)
234/// 2. Local pre-dedup: merge same-name entities from different chunks
235/// 3. Dedup sequentially against the DB (each call sees prior results)
236/// 4. Create relationships sequentially (fast, no LLM)
237/// 5. Record which entities the session touched (passive was-used signal)
238async fn process_extraction(
239    gm: &GraphMemory,
240    chunks: &[String],
241    context: &IngestContext,
242    llm: &dyn LlmProvider,
243    report: &mut IngestionReport,
244) -> Result<(), GraphError> {
245    let session_id = context.session_id.as_str();
246    let log_number = context.log_number;
247    // Phase 1: Extract all chunks in parallel.
248    // The per-chunk futures are built by the iterator, not by a stream
249    // combinator: a closure applied inside the stream would have to be
250    // higher-ranked over the item lifetime, which makes the whole stream
251    // non-`Send` and would bar ingestion from the serve daemon's tasks.
252    let pending: Vec<_> = chunks
253        .iter()
254        .enumerate()
255        .map(|(i, chunk)| extract_indexed(llm, chunk, session_id, log_number, i))
256        .collect();
257    let extraction_results: Vec<(usize, Result<ChunkExtraction, GraphError>)> =
258        stream::iter(pending)
259            .buffer_unordered(LLM_CONCURRENCY)
260            .collect()
261            .await;
262
263    // Collect entities and relationships from successful extractions. A
264    // relationship keeps the class of the chunk it came out of: the evidence
265    // is only ever as independent as the text that produced it.
266    let mut all_entities: Vec<ExtractedEntity> = Vec::new();
267    let mut all_relationships: Vec<(Provenance, ExtractedRelationship)> = Vec::new();
268
269    for (i, result) in extraction_results {
270        match result {
271            Ok((extraction, usage)) => {
272                let provenance = context.provenance.classify(&chunks[i]);
273                all_entities.extend(extract::flatten_extraction(&extraction));
274                all_relationships.extend(
275                    extraction
276                        .relationships
277                        .into_iter()
278                        .map(|rel| (provenance, rel)),
279                );
280                bill(report, usage, ESTIMATED_EXTRACTION_TOKENS);
281            }
282            Err(e) => {
283                report.errors.push(format!("extraction chunk {i}: {e}"));
284            }
285        }
286    }
287
288    // Phase 2: Local pre-dedup — merge same-name entities before hitting the DB
289    let deduplicated = local_merge_entities(all_entities);
290
291    // Phase 3: Dedup sequentially — each resolve_entity sees the full DB state
292    let mut name_map: HashMap<String, String> = HashMap::new();
293
294    for candidate in &deduplicated {
295        match dedup::resolve_entity(gm, llm, candidate, session_id).await {
296            Ok(resolution) => record_resolution(report, &mut name_map, candidate, resolution),
297            Err(e) => {
298                report
299                    .errors
300                    .push(format!("dedup '{}': {}", candidate.name, e));
301            }
302        }
303    }
304
305    // Phase 4: Create relationships or Bayesian-update existing ones
306    for (provenance, rel) in &all_relationships {
307        let from_name = name_map.get(&rel.source).unwrap_or(&rel.source);
308        let to_name = name_map.get(&rel.target).unwrap_or(&rel.target);
309
310        // Check if a relationship of the same type already exists
311        if let Some(existing) =
312            find_existing_relationship(gm, from_name, to_name, &rel.rel_type).await
313        {
314            if let Err(e) = record_reextraction(gm, &existing, *provenance).await {
315                report
316                    .errors
317                    .push(format!("confidence update {from_name} -> {to_name}: {e}"));
318            }
319            report.relationships_skipped += 1;
320            continue;
321        }
322
323        // Parse extraction context from LLM output, default to Inferred
324        let context: ExtractionContext = rel
325            .confidence
326            .as_deref()
327            .and_then(|s| s.parse().ok())
328            .unwrap_or(ExtractionContext::Inferred);
329
330        let new_rel = NewRelationship {
331            from_entity: from_name.clone(),
332            to_entity: to_name.clone(),
333            rel_type: rel.rel_type.clone(),
334            description: rel.description.clone(),
335            confidence: Some(context.prior() as f32),
336            source: Some(session_id.to_string()),
337        };
338
339        match gm.add_relationship(new_rel).await {
340            Ok(_) => report.relationships_created += 1,
341            Err(e) => {
342                report
343                    .errors
344                    .push(format!("relationship {from_name} -> {to_name}: {e}"));
345            }
346        }
347    }
348
349    // Phase 5: link the session to the entities it touched, so a later
350    // outcome knows what it applies to. Bookkeeping, never fatal: a failed
351    // link costs the feedback loop one session, not the ingestion.
352    if let Err(e) = utility::record_session_use(gm.db(), session_id, &report.entity_ids).await {
353        report
354            .errors
355            .push(format!("session use record for {session_id}: {e}"));
356    }
357
358    Ok(())
359}
360
361/// Fold a re-extracted claim into the edge that already holds it.
362///
363/// Extraction restates claims rather than denying them, so the observation is
364/// corroborating — worth what its source is worth, and tallied as coherence
365/// when the agent is restating itself, where it stays visible instead of
366/// passing for independent support.
367///
368/// The direction is passed as a value rather than assumed by the write, because
369/// the same value decides whether the decay clock moves. An extractor taught to
370/// emit negations would then lower the mean *and* keep every day of decay the
371/// edge had already accrued, instead of the denied claim coming back fresher
372/// than it went in.
373async fn record_reextraction(
374    gm: &GraphMemory,
375    existing: &Relationship,
376    provenance: Provenance,
377) -> Result<(), GraphError> {
378    let observation = Observation::Corroborating;
379    let mut evidence = existing.edge_evidence();
380    evidence.record(observation, provenance, gm.provenance_weights());
381    crud::record_observation(gm.db(), &existing.id_string(), evidence, observation).await
382}
383
384/// Fold one dedup resolution into the run's report: what it decided, and what
385/// deciding it cost.
386fn record_resolution(
387    report: &mut IngestionReport,
388    name_map: &mut HashMap<String, String>,
389    candidate: &ExtractedEntity,
390    resolution: Resolution,
391) {
392    if resolution.path.used_llm() {
393        // The fast paths make no call, so they add nothing to the bill.
394        report.dedup_llm_calls += 1;
395        bill(report, resolution.usage, ESTIMATED_DEDUP_TOKENS);
396    } else {
397        report.dedup_fast_path += 1;
398    }
399
400    match resolution.entity {
401        ResolvedEntity::Created(entity) => {
402            name_map.insert(candidate.name.clone(), entity.name.clone());
403            report.entity_ids.push(entity.id_string());
404            report.entities_created += 1;
405        }
406        ResolvedEntity::Merged(entity) => {
407            name_map.insert(candidate.name.clone(), entity.name.clone());
408            report.entity_ids.push(entity.id_string());
409            report.entities_merged += 1;
410        }
411        ResolvedEntity::Skipped => {
412            name_map.insert(candidate.name.clone(), candidate.name.clone());
413            report.entities_skipped += 1;
414        }
415    }
416}
417
418/// Charge one model call to the run: the provider's own count when it reported
419/// one, the caller's estimate when it did not.
420///
421/// The two never mix in a single number. A run that measured half its calls
422/// must be able to say so, because "13.7K measured" and "~13.7K estimated" are
423/// answers to different questions.
424fn bill(report: &mut IngestionReport, usage: Option<TokenUsage>, estimate: u64) {
425    match usage {
426        Some(usage) => report.measured_tokens += usage.total(),
427        None => report.estimated_tokens += estimate,
428    }
429}
430
431/// Merge extracted entities that share the same name (case-insensitive).
432///
433/// When multiple chunks extract the same entity, combine their data:
434/// - Keep the longest abstract_text
435/// - Concatenate overviews
436/// - Concatenate content
437/// - Deep-merge attributes (later wins on conflict)
438/// - First occurrence's entity_type wins
439fn local_merge_entities(entities: Vec<ExtractedEntity>) -> Vec<ExtractedEntity> {
440    let mut seen: HashMap<String, ExtractedEntity> = HashMap::new();
441    let mut order: Vec<String> = Vec::new();
442
443    for entity in entities {
444        let key = entity.name.to_lowercase();
445        if let Some(existing) = seen.get_mut(&key) {
446            // Keep longer abstract
447            if entity.abstract_text.len() > existing.abstract_text.len() {
448                existing.abstract_text = entity.abstract_text;
449            }
450            // Concatenate overviews
451            if let Some(new_overview) = entity.overview {
452                existing.overview = Some(match &existing.overview {
453                    Some(o) => format!("{o}\n\n{new_overview}"),
454                    None => new_overview,
455                });
456            }
457            // Concatenate content
458            if let Some(new_content) = entity.content {
459                existing.content = Some(match &existing.content {
460                    Some(c) => format!("{c}\n\n{new_content}"),
461                    None => new_content,
462                });
463            }
464            // Merge attributes
465            if let Some(new_attrs) = entity.attributes {
466                existing.attributes = Some(match &existing.attributes {
467                    Some(a) => merge_json(a, &new_attrs),
468                    None => new_attrs,
469                });
470            }
471        } else {
472            order.push(key.clone());
473            seen.insert(key, entity);
474        }
475    }
476
477    // Preserve insertion order
478    order.into_iter().filter_map(|k| seen.remove(&k)).collect()
479}
480
481use super::util::merge_json_objects as merge_json;
482
483/// Maximum length of an episode abstract, in characters.
484///
485/// The abstract is not a label: it is the text that gets embedded, the text
486/// episode search returns, and the text that reaches an answer prompt. The
487/// previous 200 severed facts mid-word and used a tenth of BGE-Small's 512-token
488/// window. 1,000 characters is half the 500-token ingest chunk — still a
489/// summary rather than a copy of the chunk — and about 250 tokens, so the whole
490/// abstract is inside the embedding window instead of being truncated by it.
491const EPISODE_ABSTRACT_MAX_CHARS: usize = 1_000;
492
493/// A boundary earlier than this share of the window discards more text than the
494/// tidiness is worth; fall back to a coarser boundary instead.
495const MIN_BOUNDARY_FRACTION: f64 = 0.6;
496
497/// Build a short abstract for an episode chunk, cut at a sentence or word
498/// boundary so a fact is never severed mid-word.
499fn build_episode_abstract(chunk: &str) -> String {
500    let trimmed = chunk.trim();
501    if trimmed.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS {
502        return trimmed.to_string();
503    }
504
505    let window: String = trimmed.chars().take(EPISODE_ABSTRACT_MAX_CHARS).collect();
506    let cut = truncation_point(&window);
507    format!("{}...", window[..cut].trim_end())
508}
509
510/// Byte offset to cut `window` at: just past the last sentence terminator if
511/// one falls late enough to keep most of the window, else the last word
512/// boundary, else the whole window.
513fn truncation_point(window: &str) -> usize {
514    let floor = (window.len() as f64 * MIN_BOUNDARY_FRACTION) as usize;
515
516    let after_sentence = window
517        .char_indices()
518        .rev()
519        .find(|(_, c)| matches!(c, '.' | '!' | '?' | '\n'))
520        .map(|(i, c)| i + c.len_utf8());
521    if let Some(cut) = after_sentence.filter(|&cut| cut >= floor) {
522        return cut;
523    }
524
525    window
526        .char_indices()
527        .rev()
528        .find(|(_, c)| c.is_whitespace())
529        .map(|(i, _)| i)
530        .filter(|&cut| cut >= floor)
531        .unwrap_or(window.len())
532}
533
534/// Find an existing relationship of the same type between two entities.
535/// Returns the full Relationship if found (for Bayesian update).
536async fn find_existing_relationship(
537    gm: &GraphMemory,
538    from_name: &str,
539    to_name: &str,
540    rel_type: &str,
541) -> Option<Relationship> {
542    let rels = gm
543        .get_relationships(from_name, Direction::Outgoing)
544        .await
545        .ok()?;
546    let to_entity = gm.get_entity(to_name).await.ok()??;
547    let to_id = to_entity.id_string();
548
549    rels.into_iter().find(|r| {
550        r.rel_type == rel_type && {
551            let out_id = match &r.to_id {
552                serde_json::Value::String(s) => s.clone(),
553                other => other.to_string(),
554            };
555            out_id == to_id
556        }
557    })
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn episode_abstract_truncates_at_the_cap() {
566        let long = "x".repeat(EPISODE_ABSTRACT_MAX_CHARS * 2);
567        let abs = build_episode_abstract(&long);
568        assert!(abs.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS + 3);
569        assert!(abs.ends_with("..."));
570    }
571
572    #[test]
573    fn episode_abstract_short_unchanged() {
574        let short = "Hello world";
575        let abs = build_episode_abstract(short);
576        assert_eq!(abs, "Hello world");
577    }
578
579    /// A chunk that would have been cut at 200 characters now survives whole.
580    #[test]
581    fn episode_abstract_keeps_text_the_old_cap_would_have_cut() {
582        let chunk = format!(
583            "{} Currently, my favourite is Kansas City Masterpiece.",
584            "Padding sentence about barbecue. ".repeat(8)
585        );
586        assert!(chunk.chars().count() > 200);
587        let abs = build_episode_abstract(&chunk);
588        assert!(abs.contains("Kansas City Masterpiece"));
589    }
590
591    /// The defect this pins: the old cap severed `Kansas City Masterpiece`
592    /// after `Ka`. Cuts must land on a sentence boundary when one is available.
593    #[test]
594    fn episode_abstract_cuts_at_a_sentence_boundary() {
595        let chunk = format!(
596            "{}My favourite is Kansas City Masterpiece and nothing else comes close at all",
597            "The barbecue discussion continued at length. ".repeat(22)
598        );
599        let abs = build_episode_abstract(&chunk);
600        assert!(abs.ends_with("at length...."), "unexpected tail: {abs:?}");
601        assert!(!abs.contains("Kansas"));
602    }
603
604    /// With no sentence terminator in reach, the cut still lands between words.
605    #[test]
606    fn episode_abstract_never_cuts_mid_word() {
607        let chunk = "barbecue ".repeat(300);
608        let abs = build_episode_abstract(&chunk);
609        let body = abs.strip_suffix("...").expect("truncated");
610        assert!(
611            body.ends_with("barbecue"),
612            "cut landed mid-word: {:?}",
613            &body[body.len().saturating_sub(20)..]
614        );
615    }
616
617    #[test]
618    fn user_only_chunk_is_credited_to_the_human() {
619        let chunk = "### User\n\nI moved the repo to /opt/recall-echo.";
620        assert_eq!(infer_from_turn_roles(chunk), Provenance::User);
621    }
622
623    #[test]
624    fn assistant_turns_make_a_chunk_self_authored() {
625        let chunk = "### Assistant\n\nThe repo now lives at /opt/recall-echo.";
626        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
627    }
628
629    #[test]
630    fn mixed_chunk_is_self_authored() {
631        // The conservative half of the rule: a chunk the agent contributed to
632        // cannot be counted as independent testimony.
633        let chunk = "### User\n\nWhere does it live?\n\n---\n\n### Assistant\n\n/opt.";
634        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
635    }
636
637    #[test]
638    fn text_without_role_headings_is_self_authored() {
639        let chunk = "A pipeline document with no conversation structure at all.";
640        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
641    }
642
643    #[test]
644    fn heading_matching_is_exact() {
645        // "### Users of the system" is a topic, not a turn.
646        let chunk = "### Users of the system\n\nThey prefer NeoVim.";
647        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
648    }
649
650    #[test]
651    fn fixed_policy_overrides_turn_roles() {
652        let chunk = "### User\n\nA quote from a paper.";
653        let policy = ProvenancePolicy::Fixed(Provenance::External);
654        assert_eq!(policy.classify(chunk), Provenance::External);
655        assert_eq!(
656            ProvenancePolicy::FromTurnRoles.classify(chunk),
657            Provenance::User
658        );
659    }
660
661    #[test]
662    fn context_override_is_applied_only_when_present() {
663        let context = IngestContext::new("s1", Some(7));
664        assert_eq!(context.session_id(), "s1");
665        assert_eq!(context.log_number(), Some(7));
666
667        let inferring = context.clone().with_override(None);
668        assert_eq!(inferring.provenance, ProvenancePolicy::FromTurnRoles);
669
670        let forced = context.with_override(Some(Provenance::External));
671        assert_eq!(
672            forced.provenance,
673            ProvenancePolicy::Fixed(Provenance::External)
674        );
675    }
676}